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

GET https://api.poly.io/og?url={URL}

And one for checking your own allowance, which does not count against it:

GET https://api.poly.io/og/quota

Parameters

NameRequiredDescription
urlYesThe page to inspect, http or https. URL-encode it.
uaNoFetch 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.
siteNoAn 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.
capNoWith site, the longest plan to return. Clamped to 1–100; defaults to 40.
contactNoAn email or URL identifying you. Doubles your daily allowance to 2,000 with no key and no signup. Prefer the x-og-contact header.
keyNoAn 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

$ curl 'https://api.poly.io/og?url=https%3A%2F%2Fexample.com'
{ "ok": true, "finalUrl": "https://example.com/", "host": "example.com", "title": "Example Domain", "description": "", "siteName": "", "card": "", "imageUrl": "", "imageOk": false, "otherImage": null, "views": [ { "platforms": ["X", "Facebook", "LinkedIn", "WhatsApp", "Slack"], "title": "Example Domain", "description": "", "imageUrl": "", "uncertain": null, "width": null, "height": null, "imageOk": false, "reads": "og:* and twitter:* resolve alike here" } ], "imageWidth": null, "imageHeight": null, "uaDiff": [], "robotsBlocked": null, "crawlerRefused": null, "namedCrawlers": null, "checks": [ { "level": "ok", "id": "title.ok", "message": "Title present (14 chars)." }, { "level": "warn", "id": "description.missing", "message": "No description — set og:description." }, { "level": "error", "id": "image.missing", "message": "No og:image — most platforms show a bare link with no thumbnail." }, { "level": "warn", "id": "type.missing", "message": "No og:type — set one (e.g. \"website\" or \"article\")." }, { "level": "warn", "id": "url.missing", "message": "No og:url — set the canonical URL so every share resolves to one address." }, { "level": "tip", "id": "site-name.missing", "message": "Add og:site_name so platforms show your brand, not the bare domain." }, { "level": "warn", "id": "card.missing", "message": "No twitter:card — X/Twitter shows a small summary; set summary_large_image for the big card." }, { "level": "ok", "id": "ua-diff.same", "message": "Crawlers and browsers get the same title, description and image." } ], "source": "https://www.poly.io/social-card-preview/api/", "quota": "https://api.poly.io/og/quota" }

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).

FieldTypeDescription
okbooleantrue on success; false with an error string if the URL couldn’t be read.
errorstringPresent only when ok is false — a human-readable reason.
codestringPresent 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.
statusnumberThe HTTP status the target returned, when code is target_http_error. null otherwise.
finalUrlstringThe URL after following redirects.
hoststringHostname of finalUrl.
quotastringWhere 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.
sourcestringLink to these docs. Present on every response, so anything built on this API carries a pointer back to it.
titlestringog:title, falling back to twitter:title then the page <title>.
descriptionstringog:description, falling back to twitter:description.
siteNamestringog:site_name, if set.
cardstringtwitter:card type (e.g. summary_large_image), if set.
imageUrlstringResolved og:image / twitter:image (absolute URL).
imageOkbooleanWhether the image was fetched and is a real image, server-side.
imageWidth, imageHeightnumberPixel dimensions when the image could be read (PNG, JPEG, GIF, WebP); null when it could not.
uaDiffarrayFields 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.
robotsBlockedstringThe 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.
crawlerRefusedobjectSet 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.
namedCrawlersarrayOnly 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.
viewsarrayWhat 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.
otherImageobjectSet 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.
checksarrayDiagnostics — 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:

levelMeaning
tipOptional best-practice most good sites still skip (og:image:alt, og:image:width/height, og:site_name) — worth doing, not a fault.
okPresent and within sensible bounds.
warnRenders, but degraded — a title that truncates, an http or under-sized image, a missing description.
errorWill 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 no ok field. 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: false with an explanation, not a dead endpoint — and a rejection carries x-ratelimit-limit and x-ratelimit-remaining as counts, and retry-after and x-ratelimit-reset as 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 as limit, remaining and resetSeconds. 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-contact header (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: false immediately 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.

{ "ok": true, "limit": 500, "used": 137, "remaining": 363, "resetSeconds": 40853, "identified": false, "upgrade": "Add an \"x-og-contact: you@example.com\" header (or ?contact=) for 2,000 a day.", "exact": true }
FieldTypeDescription
limitnumberRequests a day for whoever is asking — 500 anonymous, 2,000 identified, 5,000 with a key.
usednumberRequests counted against you so far today.
remainingnumberlimit minus used, floored at zero.
resetSecondsnumberSeconds until the count resets, at midnight UTC.
identifiedbooleanWhether the request carried a contact header or a valid key.
upgradestringWhat would raise your allowance, or null if you are already on a key.
exactbooleanfalse 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 does.

Talk to us Back to the tool