Open Graph API
A plain JSON endpoint that reads any URL’s Open Graph and Twitter card tags — title, description, image (fetched and validated, with dimensions) and a checklist of what’s missing or off. Built for scripts, CI checks and AI agents. Free, no key, CORS-enabled.
Endpoint
And one for checking your own allowance, which does not count against it:
Machine-readable: OpenAPI 3.1 describes every parameter, all 21 error codes with their retryability, and the 87 stable check ids — and /llms.txt is the short version. There is a Postman collection to import, and a requests.http that VS Code's REST Client and the JetBrains HTTP client both run as they are. All four are generated from the same source as this page, so a parameter cannot exist in one and not the others.
Parameters
| Name | Required | Description |
|---|---|---|
| url | Yes | The page to inspect, http or https. URL-encode it. Repeat it to check up to five pages in one request: see several pages in one request. |
| ua | No | Fetch as a platform’s crawler — facebook, x, linkedin, slack, discord or browser. Sites serve different HTML by User-Agent (consent walls, bot gates), so this reproduces what that platform actually reads. Defaults to our own crawler. |
| site | No | An origin instead of url. Reads the site’s sitemap, clusters the URLs by shape and returns a plan: one representative page per template. A 4,000-page blog is one template, and checking all 4,000 costs 4,000 times what checking one does to prove the same thing. With a path, such as example.com/blog/, or /* for the whole site, the plan is the pages the sitemap lists under it instead, up to cap, and under.urls lists up to 1,000 of them, so a caller can tell which are new. |
| cap | No | With site, the longest plan to return. Clamped to 1–100; defaults to 40. |
| contact | No | An email or URL identifying you. Doubles your daily allowance to 2,000 with no key and no signup. Prefer the x-og-contact header. |
| key | No | An API key, if you have one. Prefer the x-og-key header — a key in a query string ends up in server logs, browser history and Referer headers. |
Example
That is the whole response, verbatim — not an abridged one.
example.com carries almost no card tags, which is why the checks do the talking
and why most fields come back empty. A fully-tagged page fills in siteName,
card, imageUrl and the image dimensions, and splits
views wherever two platforms would not see the same card.
Response
Always HTTP 200 with a JSON body. Check the ok field, not the status code (see Errors & limits).
| Field | Type | Description |
|---|---|---|
| ok | boolean | true on success; false with an error string if the URL couldn’t be read. |
| error | string | Present only when ok is false — a human-readable reason. |
| code | string | Present only when ok is false — a stable identifier to branch on instead of matching the English. All 21 are listed under Errors & limits. |
| retryable | boolean | Present only on failures. false means the request will never succeed as written — a blocked address, a hostname that doesn’t resolve, a malformed URL — so a retry loop should stop rather than spend someone else’s bandwidth on it. target_http_error is judged on its status: a 503 is retryable, a 404 is not. |
| status | number | The HTTP status the target returned, when code is target_http_error. null otherwise. |
| finalUrl | string | The URL after following redirects. |
| host | string | Hostname of finalUrl. |
| quota | string | Where to ask how much allowance you have left, without spending any: see below. On every response, so you need not be refused first to find out it exists. |
| source | string | Link to these docs. Present on every response, so anything built on this API carries a pointer back to it. |
| spec | string | The machine-readable twin of source: every error code with its retryability, the always-200 contract, and a whole example response. Fetch this rather than this page if you are a script. |
| specVersion | string | The contract version this response came from. Log it; don’t pin it — every ordinary release would fail your build on our schedule. See when this changes. |
| specNext | string or null | Set only while a breaking change is scheduled: the version and the date it lands. Null the rest of the time, so surfacing it as a warning stays quiet until it matters. |
| fresh | boolean | Present only when you asked for ?fresh=1. Confirms the five-minute cache was bypassed and this answer was fetched just now. |
| title | string | og:title, falling back to twitter:title then the page <title>. |
| description | string | og:description, falling back to twitter:description. |
| titleLength, descriptionLength | number | Length in glyphs of the two fields above. Gate on whichever budget suits your design — how much a card actually shows depends on a width and a font this API cannot see, so it reports the measurement and leaves the verdict to you. |
| siteName | string | og:site_name, if set. |
| card | string | twitter:card type (e.g. summary_large_image), if set. |
| imageUrl | string | Resolved og:image / twitter:image (absolute URL). |
| imageOk | boolean | Whether the image was fetched and is a real image, server-side. |
| imageWidth, imageHeight | number | Pixel dimensions when the image could be read (PNG, JPEG, GIF, WebP); null when it could not. |
| uaDiff | array | Fields that differ between what a crawler sees and what a browser sees — each {field, crawler, browser}. Empty when they agree; null when not compared — either you asked for ua=browser, or the crawler fetch failed, which is precisely when crawlerRefused is set. A non-empty array means a consent wall or bot gate is changing the page, which is why a card can look right to you and be broken when shared. |
| robotsBlocked | string | The robots.txt rule disallowing the image path, if any — Facebook documents that it reads robots.txt, while reserving the right to bypass it for security or integrity checks, and says nothing specific about og:image. So a match makes a picture-less card likely rather than certain: title and description only, not no card at all. null when nothing blocks it or no rules were found. |
| crawlerRefused | object | Set when the site served a browser but turned the crawler away — {status, error}, with status the HTTP code it refused with, or null for a refused connection. The preview then falls back to the browser’s copy, so you still get a card and the finding. It does not imply the platforms are refused too — an allowlist that names the crawlers it knows refuses us and serves them. See namedCrawlers, which measures it. null when the crawler was served normally. |
| namedCrawlers | array | Only set when crawlerRefused is — the result of asking again as the two crawlers whose behaviour is documented. Each {name, allowed, status} for Facebook and X. All refused means a blanket bot block; all allowed means an allowlist, and your card is probably fine on the platforms it names. Two agents is not every platform, so the rest is inference. null otherwise. |
| views | array | What each platform resolves, grouped by the answer — one entry when they all agree, more when they don’t. Each {platforms, reads, title, description, imageUrl, imageOk, width, height, uncertain}. reads names the namespace chain that group follows. uncertain is an array of field names, or null: it is set for a platform documented to read both namespaces without publishing a precedence — Slack — on a page whose two namespaces disagree. The card in that entry is the Open Graph answer; the X one is equally possible. Only the resolution is modelled here, deliberately not crop ratios or truncation limits. |
| otherImage | object | Set when og:image and twitter:image point at different files — {url, ok, w, h, bytes, type} for the one the top-level imageUrl fields do not describe. Both are fetched, because reporting one of two deliberately different pictures describes a card half the audience never sees. null when there is only one image. |
| checks | array | Diagnostics — see below. |
checks
Each entry is { "level": …, "id": …, "message": … }.
Key off id, never off message. The id
names the condition and is stable; the message is prose and gets rewritten whenever
we find a clearer or more accurate way to say it. In September 2026 four messages changed in one
afternoon, because they were claiming more than the tool had measured — anything matching on
their text would have quietly stopped matching. Ids are added when checks are added, and a rename
is treated as a breaking change.
level is the severity:
| level | Meaning |
|---|---|
| tip | Optional best-practice most good sites still skip (og:image:alt, og:image:width/height, og:site_name) — worth doing, though nothing is broken without them. |
| ok | Present and within sensible bounds. |
| warn | Renders, but degraded — a title that truncates, an http or under-sized image, a missing description. |
| error | Will visibly break the card — no title, or no image at all. |
The thresholds are grounded in the Open Graph protocol (which tags are required vs optional) and the platform docs (Facebook, X’s Cards, LinkedIn) for sizes and truncation — then sanity-checked against a corpus of real sites, so well-built pages stay quiet and only genuine problems flag.
Several pages in one request
Repeat url to check up to five pages at once. Each page counts as one request
against your allowance, and a page listed twice is checked and counted once. The pages are fetched
concurrently, so the answer takes as long as the slowest of them.
The answer is {"ok": true, "batch": true, "count": 2, "results": […]}. Each entry in
results is what a request for that page alone would return, with its own
ok, code, retryable and checks, plus the
url as you sent it, in the order you sent them. The top-level ok says the
request was understood; each result’s ok is about its page.
- More than five
urlvalues are refused whole withtoo_many_urls, and nothing is checked. - With
site,urlis ignored. - URL-encode every value. A literal
&or+in a page’s address otherwise splits or changes it. - A batch is cached as one answer for its list of pages, and not at all if any page in it would not be.
Gating a build on it
Every response is HTTP 200, so a gate reads the body. This one fails a build on anything that will visibly break the card:
Test ok before reaching for checks. A refusal carries no
checks at all — a URL that will not parse, a host that refuses crawlers, or your allowance gone
answers ok: false with a code and nothing else. A gate that goes straight to
.checks dies on Cannot iterate over null, which fails the build for a reason
nobody reading the log can act on. Both recipes here test ok first, and name the code
when they stop.
Two gates are stable: level == "error", and a list of check ids you name yourself.
Gating on every warn is a third option with a cost worth knowing. New checks arrive at
warn or tip, so one of ours can turn your build red on our release
schedule; every addition is logged in the change log below, so it never
arrives unannounced.
For text length, gate on the number. description.long fires at 200 characters,
which is the one limit with a primary source. Most cards clamp the description to two lines and
stop well before that, at a width and a font this API never sees, so pick the budget your own
design needs:
Give CI an allowance it will not run out of
The anonymous allowance is 500 requests a day and it is counted per network address, which a build server shares across every job on it. A repository running twenty pull requests a day over thirty URLs passes that before lunch. A gate that starts failing when the allowance runs out is worse than no gate, because it fails on a day when nothing was wrong and teaches everyone to re-run it.
So identify the build. A free key raises it to 5,000 a day; sending an
x-og-contact header raises it to 2,000 with no key
and no signup. quota_exceeded is marked retryable for the same reason:
it means the check did not run, not that the card is broken, and CI should say so rather than
failing the change under review.
Errors & limits
- Two exceptions to the 200 rule, and neither is ours. A shared burst limit of 10 requests a second (bursting to 20) sits in front of this API; exceeding it returns HTTP 429 from the gateway with a plain
{"message":"Too Many Requests"}. Under extreme load the compute layer can also answer HTTP 503 with{"message":"Service Unavailable"}. Neither carries anokfield, and both are worth retrying after a moment. Everything below concerns the API itself. - Application errors (bad URL, unreachable host, blocked address) return HTTP 200 with
{ "ok": false, "error": "…" }— so a single check is enough; you needn’t branch on status codes. - Rate limiting. A shared burst limit of 10 requests/second, plus a
daily allowance per caller: 500 requests a day anonymously,
2,000 if you identify yourself, or 5,000 with a free
API key — both below. Over the allowance you get
ok: falsewith an explanation, and a rejection carriesx-ratelimit-limitandx-ratelimit-remainingas counts, andretry-afterandx-ratelimit-resetas seconds to the next reset at midnight UTC, so a script knows how long to wait. The same three numbers are in the body aslimit,remainingandresetSeconds. They ride on a rejection only: a card is cached and shared between callers, so a remaining count attached to one would be served to the next. Responses are cached ~5 minutes per URL, so repeat calls of the same URL are free. The exception is a URL on a platform’s own site, or one that redirects there: that answer depends on your key, so it is never cached. - Identifying yourself — optional, no signup. Send an
x-og-contactheader (or&contact=) with an email or a URL and your daily allowance goes up to 2,000. No key, no account: it just gives us a way to reach you. - An API key — free, for a bigger allowance. 5,000 requests a day, counted against the key rather than your IP address. How to get one.
- Some sites can’t be read at all. A few gate every unauthenticated
fetch, so no crawler — ours or a real platform’s — can see a card.
Reddit is the current example: those return
ok: falseimmediately with the reason, rather than making you wait for a timeout. - SSRF-guarded. It won’t fetch private, loopback, link-local or cloud-metadata addresses, and re-checks every redirect hop.
- CORS.
Access-Control-Allow-Origin: *— callable straight from browser JavaScript.
Every error code
Generated from the handler itself, so this list cannot drift from what the API returns — it previously named two codes that did not exist and omitted four that did. Retryable means the same request could succeed later; everything else is permanent as written.
| Code | Retry? | Meaning |
|---|---|---|
no_url | no | No url (or site) parameter was given. |
bad_url | no | The url could not be parsed. |
bad_scheme | no | The url is not http or https. |
bad_ua | no | The ua parameter is not one of the supported crawler names. |
bad_key | no | The key is not shaped like an API key. |
unknown_key | no | The key is well-formed but not recognised. |
revoked_key | no | The key was recognised and has been revoked. |
bad_site | no | The site parameter could not be parsed as an origin. |
no_sitemap | no | No sitemap was found — neither a Sitemap: line in robots.txt nor /sitemap.xml answered. |
empty_sitemap | no | A sitemap was found but held no usable URLs. |
other_origin | no | The sitemap lists pages on a different host; the error names which one to scan instead. |
blocked_address | no | The URL resolves to an address this API refuses on principle (loopback, link-local, private ranges). Permanent. |
host_not_found | no | The hostname does not resolve. Usually a typo — distinct from an address we refuse. |
gated_host | no | Not fetched, for the reason given in error: the host blocks unauthenticated fetches, so no crawler can read its card (reddit.com); the page is on TikTok, Instagram, Facebook, X or LinkedIn and the request carries no API key (from 27 September 2026); or ua= names the crawler of the site being fetched. Permanent for that request. |
not_html | no | The URL returned something that is not HTML — an image or a PDF, say. |
target_http_error | no | The page returned an HTTP error. Retryable only on 5xx; the status is in the response. |
target_unreachable | yes | The page could not be reached — a timeout or a refused connection. |
target_too_large | no | The page exceeded the fetch ceiling. |
target_failed | no | The fetch failed for a reason with no more specific code. |
quota_exceeded | yes | The daily allowance is spent. Resets on the UTC day; see the quota endpoint. |
too_many_urls | no | The request repeats url more times than one request may. Nothing was checked; split the pages across several requests. |
Checking your allowance
GET /og/quota answers with today’s usage for whoever is asking. Send the same
x-og-contact or x-og-key header you would send with a request and it
reports the allowance that header earns you. Asking does not spend a request, so a
script can check once a run rather than discovering the limit by hitting it.
| Field | Type | Description |
|---|---|---|
| limit | number | Requests a day for whoever is asking — 500 anonymous, 2,000 identified, 5,000 with a key. |
| used | number | Requests counted against you so far today. |
| remaining | number | limit minus used, floored at zero. |
| resetSeconds | number | Seconds until the count resets, at midnight UTC. |
| identified | boolean | Whether the request carried a contact header or a valid key. |
| upgrade | string | What would raise your allowance, or null if you are already on a key. |
| exact | boolean | false if the counter could not be read, in which case used is a floor and not a reading. The allowance that actually applies is always the one enforced on a request, never this answer. |
The same three numbers come back as x-ratelimit-limit,
x-ratelimit-remaining and x-ratelimit-reset headers. This endpoint is
never cached — the answer depends entirely on who is asking — which is also why those
headers appear here and on a refusal, but not on a successful card: cards are cached and shared
between callers, so a remaining count attached to one would be handed to the next.
Every check id
All 87, grouped by the level they report at. Ids are stable and a rename is treated as a breaking change; the messages beside them are not, and are rewritten whenever a clearer or more accurate wording is found. Each problem id links to its reference page: when it is raised and how to fix it.
When this changes
Your gate is probably “no checks at level error”. So a new check arriving at error, or an existing one promoted to it, would turn your builds red on our release schedule — with no id removed and no field retyped. That is a breaking change from where you are sitting, and it is the one a version number alone never warns you about.
- New checks land at
warnortip. Never aterror. - Raising a check to
erroris breaking, and is announced before it ships. - So is removing or renaming a check id, removing or retyping a response field, or removing an error code.
- Adding a check at
warn, or a new response field, is not.
changes.json carries the dated log and the current inventory as {id, level} — diff it against the ids you gate on. Every response carries specVersion, and specNext when a breaking change is scheduled.
Don’t pin specVersion in CI. Every ordinary release would fail your build on our schedule. Log it, and surface specNext as a warning instead:
[ -z "$next" ] || echo "::warning ::OG API change scheduled: $next"
How it works
It fetches the page and reads its Open Graph and Twitter-card tags — title, description, image — then fetches the image itself to check it really exists and what shape it is. The checklist flags what’s missing or off: no image, a title that’ll truncate, an image that’s too small or the wrong shape. The preview tool renders the same data as a card at three sizes: large, compact, and the small thumbnail most feeds use.
Tips
- Use a 1200×630 image (1.91:1).
- Keep the title short — it has to read at thumbnail size.
- Set a per-page image, not one site-wide default.
- Re-scrape after changes. Facebook documents a 24-hour cache; LinkedIn publishes no figure.
Get a free key
Keys are free and raise the allowance to 5,000 requests a day. Send yours as an x-og-key header. Unlike the contact header, a key is counted against the key rather than your IP address — so the allowance follows you across machines, containers and CI runners rather than resetting with every new one.
To get one, email enquiries@poly.io and say roughly what you’re building. Issuance is manual on purpose: a person reads it, which is also your chance to say if the API is missing something you need. Need more than 5,000 a day? Say so and we’ll talk.
We store a label, the contact address you give us, and a one-way hash of the key — never the key itself, so we cannot tell you what it was if you lose it. Ask us and we’ll revoke it; we may also revoke one that’s being abused. What we keep and why.
Need more than fair use?
This is free for everyone. If you want it under load — higher limits, an SLA, or the same approach turned into an API of your own — that’s what polymorphism does.