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:
Parameters
| Name | Required | Description |
|---|---|---|
| url | Yes | The page to inspect, http or https. URL-encode it. |
| 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 — rather than checking every page. 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. |
| 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. no_url, bad_url, bad_scheme, bad_ua, bad_key, unknown_key, revoked_key and quota_exceeded mean your request; gated_host, not_html, target_http_error, target_unreachable and target_too_large mean the page you asked about. |
| 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. |
| title | string | og:title, falling back to twitter:title then the page <title>. |
| description | string | og:description, falling back to twitter:description. |
| 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 honours robots.txt when fetching og:image, so a match means the card unfurls there without its picture — 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, not a fault. |
| 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.
Errors & limits
- One exception to the 200 rule, and it is not ours. A shared burst limit of a few requests a second sits in front of this API; exceeding it returns HTTP 503 from the gateway with a plain
{"message":"Service Unavailable"}and nookfield. Send requests in series, or retry a 503 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 a few 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, not a dead endpoint — and a rejection carriesx-ratelimit-limitandx-ratelimit-remainingas counts, andretry-afterandx-ratelimit-resetas seconds to the next reset at midnight UTC, so a script can wait rather than ask again. 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. - 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 rather than guess. - 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.
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.
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; LinkedIn caches for about a week.
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.