Check hreflang tag consistency and reciprocal links
Hreflang clusters only work reliably when every participating page declares the same complete set of alternatives, including a self-reference.
Run — free
This checker compares mappings collected from multiple pages and exposes incomplete clusters without fetching any URLs. It builds the expected page set from all source and target URLs, checks every source against that set, and returns the precise links missing from each page. The result is deterministic, suitable for release checks, migrations, and repeatable SEO quality-control workflows.
Why complete reciprocal clusters matter
An hreflang annotation is not merely a one-way recommendation from one page to another. It describes membership in a group of equivalent language or regional pages, and every page in that group should publish the same set of destinations. That includes a link to itself. When an English page names French and German alternatives but the French page only names English, the cluster is incomplete even though several individual tags look correct. Search engines may ignore unsupported relationships, leaving the wrong language version eligible for a query or weakening the intended regional signal. Manual review becomes unreliable as clusters grow because the number of relationships increases quickly. A four-page cluster needs each of its four pages to declare all four destinations, which means sixteen source-to-target relationships must be present. This checker turns that visual comparison into an exact set test. It reports consistency only when every page represented by the submitted mappings declares every page in the cluster, making omissions and missing self-references explicit rather than hiding them behind a summary count.
How to prepare and interpret the mappings
Submit one record for each hreflang declaration you observed. The page_url identifies the page containing the tag, language records the tag value, and target_url identifies its destination. Include records from every page that is intended to belong to the cluster. The checker derives the expected cluster from the union of source and target URLs. This detail catches an especially useful failure: if one page is referenced by its siblings but contributes no declarations of its own, it still enters the expected set and is reported as missing every destination. URLs are compared as exact strings after surrounding whitespace is removed, so your collection process should use the canonical absolute form emitted by the pages. The output contains a page summary and a conflicts list. A complete page has declared every URL in the derived cluster. An incomplete page includes missing_urls, which lists the precise destinations that must be added. The consistent field is true only when the conflicts list is empty; duplicate declarations do not manufacture completeness because destinations are compared as a set.
Use it in launches, migrations, and recurring audits
The most valuable time to run a reciprocity check is before a multilingual release reaches crawlers. Export the generated hreflang declarations from the rendered pages, convert them into mapping records, and fail the release when consistent is false. This catches template branches that omit the current page, regional rollouts that update only the new market, and migrations where one locale still emits URLs from the old structure. The same check also fits a scheduled audit after editors add or retire locales. Because the algorithm performs no network requests, it does not prove that a destination responds successfully, is canonical, or contains useful equivalent content; those are separate crawling and editorial checks. Its narrow promise is stronger: for the exact mapping set supplied, it determines whether every represented page lists every other represented page and itself. API requests use the published base price of $0.002, while the browser implementation can run the same pure comparison locally. Store the returned conflicts beside deployment evidence so corrections can be traced to a specific source page and missing target rather than a vague cluster-level warning.
What you can do with it
Validate a multilingual launch
Check that every newly launched locale and every existing sibling page publish the complete shared alternate set before release.
Audit a platform migration
Compare mappings emitted by new templates and identify pages that lost self-references or return links during URL migration.
Guard an SEO deployment pipeline
Turn an inconsistent cluster into a deterministic failed quality check with exact missing source-to-target relationships.
FAQ
What makes an hreflang cluster consistent?
Every URL represented in the cluster must declare every represented URL, including itself. The checker reports true only when all those relationships exist.
Does the checker fetch the pages?
No. It only compares the mappings you provide, so it is deterministic and does not verify HTTP status, canonical tags, or page content.
How are missing source pages detected?
The expected cluster is built from both source and target URLs. A URL that appears only as a target is therefore reported as a page with missing declarations.
Are duplicate declarations treated as conflicts?
Duplicate source-to-target declarations do not affect completeness. Destinations are compared as a set, while the mapping count still reflects all submitted records.
Are URL variants normalized?
No. URLs are compared exactly after trimming surrounding whitespace. Normalize scheme, host, path, and trailing-slash conventions before submitting mappings.
What does an API request cost?
Each request uses the published base price of $0.002. The comparison does not make network or model calls.
For developers — API access
Everything on this page is available programmatically. This section is for teams who want to wire it into their own systems; everyone else can just use the tool above.
API endpoint
Prefer to automate it? One authenticated POST creates the task; the result comes back by webhook or a signed link. The same capability also runs here on the web, by email and from Telegram — and soon from our app too.
Call it from your stack
curl -X POST https://api.kit.forhosting.com/seo/hreflang-conflict-check \
-H "Authorization: Bearer $KIT_KEY" \
-H "Content-Type: application/json" \
-d '{"mappings":[{"page_url":"https://example.com/en","language":"en","target_url":"https://example.com/en"},{"page_url":"https://example.com/en","language":"fr","target_url":"https://example.com/fr"},{"page_url":"https://example.com/fr","language":"en","target_url":"https://example.com/en"},{"page_url":"https://example.com/fr","language":"fr","target_url":"https://example.com/fr"}]}'const res = await fetch("https://api.kit.forhosting.com/seo/hreflang-conflict-check", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.KIT_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"mappings": [
{
"page_url": "https://example.com/en",
"language": "en",
"target_url": "https://example.com/en"
},
{
"page_url": "https://example.com/en",
"language": "fr",
"target_url": "https://example.com/fr"
},
{
"page_url": "https://example.com/fr",
"language": "en",
"target_url": "https://example.com/en"
},
{
"page_url": "https://example.com/fr",
"language": "fr",
"target_url": "https://example.com/fr"
}
]
})
});
const { task_id } = await res.json();import os, requests
res = requests.post(
"https://api.kit.forhosting.com/seo/hreflang-conflict-check",
headers={"Authorization": f"Bearer {os.environ['KIT_KEY']}"},
json={
"mappings": [
{
"page_url": "https://example.com/en",
"language": "en",
"target_url": "https://example.com/en"
},
{
"page_url": "https://example.com/en",
"language": "fr",
"target_url": "https://example.com/fr"
},
{
"page_url": "https://example.com/fr",
"language": "en",
"target_url": "https://example.com/en"
},
{
"page_url": "https://example.com/fr",
"language": "fr",
"target_url": "https://example.com/fr"
}
]
},
)
task_id = res.json()["task_id"]<?php
$res = file_get_contents("https://api.kit.forhosting.com/seo/hreflang-conflict-check", false, stream_context_create([
"http" => [
"method" => "POST",
"header" => "Authorization: Bearer " . getenv("KIT_KEY") . "\r\nContent-Type: application/json",
"content" => '{"mappings":[{"page_url":"https://example.com/en","language":"en","target_url":"https://example.com/en"},{"page_url":"https://example.com/en","language":"fr","target_url":"https://example.com/fr"},{"page_url":"https://example.com/fr","language":"en","target_url":"https://example.com/en"},{"page_url":"https://example.com/fr","language":"fr","target_url":"https://example.com/fr"}]}',
],
]));
$task = json_decode($res, true);body := bytes.NewBufferString(`{"mappings":[{"page_url":"https://example.com/en","language":"en","target_url":"https://example.com/en"},{"page_url":"https://example.com/en","language":"fr","target_url":"https://example.com/fr"},{"page_url":"https://example.com/fr","language":"en","target_url":"https://example.com/en"},{"page_url":"https://example.com/fr","language":"fr","target_url":"https://example.com/fr"}]}`)
req, _ := http.NewRequest("POST", "https://api.kit.forhosting.com/seo/hreflang-conflict-check", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KIT_KEY"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)Example request
{
"mappings": [
{
"page_url": "https://example.com/en",
"language": "en",
"target_url": "https://example.com/en"
},
{
"page_url": "https://example.com/en",
"language": "fr",
"target_url": "https://example.com/fr"
},
{
"page_url": "https://example.com/fr",
"language": "en",
"target_url": "https://example.com/en"
},
{
"page_url": "https://example.com/fr",
"language": "fr",
"target_url": "https://example.com/fr"
}
]
}Example response
{
"task_id": "tsk_a1b2c3d4e5f6a1b2c3d4e5f6",
"type": "seo.hreflang_conflict_check",
"status": "queued",
"_links": {
"result": "/tasks/tsk_…/result"
}
}The API is asynchronous: the call returns a task_id immediately and the result arrives by webhook. Polling is capped at 1 req/s per task.
Pricing
Published price — no tokens, no invented credits. A failed task is never charged.
Errors
| HTTP | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing or invalid API key. |
402 | insufficient_balance | Your balance doesn't cover the task price. |
404 | unknown_type | That task type doesn't exist. |
429 | rate_limited | Too many requests. Use the webhook instead of polling. |