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

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

NameRequiredDescription
urlYesThe 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.
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. 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.
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": "", "titleLength": 14, "descriptionLength": 0, "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/", "spec": "https://www.poly.io/llms.txt", "specVersion": "1.7.0", "specNext": null, "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. All 21 are listed under Errors & limits.
retryablebooleanPresent 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.
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.
specstringThe 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.
specVersionstringThe 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.
specNextstring or nullSet 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.
freshbooleanPresent only when you asked for ?fresh=1. Confirms the five-minute cache was bypassed and this answer was fetched just now.
titlestringog:title, falling back to twitter:title then the page <title>.
descriptionstringog:description, falling back to twitter:description.
titleLength, descriptionLengthnumberLength 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.
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 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.
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, though nothing is broken without them.
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.

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.

$ curl -s 'https://api.poly.io/og?url=https%3A%2F%2Fexample.com%2F&url=https%3A%2F%2Fexample.org%2F'

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 url values are refused whole with too_many_urls, and nothing is checked.
  • With site, url is 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:

$ curl -s "https://api.poly.io/og?url=$URL" | jq -e 'if .ok then [.checks[] | select(.level == "error")] | length == 0 else error("could not check: \(.code)") end'

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:

$ curl -s "https://api.poly.io/og?url=$URL" | jq -e 'if .ok then .descriptionLength <= 130 and .titleLength <= 60 else error("could not check: \(.code)") end'

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.

$ curl -s -H "x-og-key: $OG_KEY" "https://api.poly.io/og?url=$URL" | jq -e 'if .ok then [.checks[] | select(.level == "error")] | length == 0 elif .code == "quota_exceeded" then "allowance reached, check skipped" | halt_error(0) else error("could not check: \(.code)") end'

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 an ok field, 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: false with an explanation, 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 knows how long to wait. 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. 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-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.
  • 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.

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.

CodeRetry?Meaning
no_urlnoNo url (or site) parameter was given.
bad_urlnoThe url could not be parsed.
bad_schemenoThe url is not http or https.
bad_uanoThe ua parameter is not one of the supported crawler names.
bad_keynoThe key is not shaped like an API key.
unknown_keynoThe key is well-formed but not recognised.
revoked_keynoThe key was recognised and has been revoked.
bad_sitenoThe site parameter could not be parsed as an origin.
no_sitemapnoNo sitemap was found — neither a Sitemap: line in robots.txt nor /sitemap.xml answered.
empty_sitemapnoA sitemap was found but held no usable URLs.
other_originnoThe sitemap lists pages on a different host; the error names which one to scan instead.
blocked_addressnoThe URL resolves to an address this API refuses on principle (loopback, link-local, private ranges). Permanent.
host_not_foundnoThe hostname does not resolve. Usually a typo — distinct from an address we refuse.
gated_hostnoNot 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_htmlnoThe URL returned something that is not HTML — an image or a PDF, say.
target_http_errornoThe page returned an HTTP error. Retryable only on 5xx; the status is in the response.
target_unreachableyesThe page could not be reached — a timeout or a refused connection.
target_too_largenoThe page exceeded the fetch ceiling.
target_failednoThe fetch failed for a reason with no more specific code.
quota_exceededyesThe daily allowance is spent. Resets on the UTC day; see the quota endpoint.
too_many_urlsnoThe 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.

{ "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.

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.

LevelIds
errortitle.missing image.bad-scheme image.missing image.refused-everywhere image.refused-unprobed image.http-error image.twitter-only format.unsupported crawler.refused-everywhere crawler.refused-partial
warnhead.truncated title.long title.twitter-only description.missing description.very-long description.long description.twitter-only image.refused-allowlisted image.not-an-image image.unreachable image.unloadable image.insecure image.relative image.too-small image.below-1200 aspect.off-target bytes.over-x-limit image.several image.expiring image.slow image.transparent images-differ.other-broken declared-size.unusable declared-size.mismatch robots.noindex type.missing url.missing url.other-host text.double-escaped card.missing card.summary-with-large-image crawler.refused-unprobed crawler.refused-allowlisted ua-diff.crawler-starved meta-refresh robots.image-blocked robots.image-blocked-undocumented
tipdescription.short description.meta-only aspect.over-whatsapp-max crop.detail-at-edge crop.wide bytes.over-whatsapp format.webp format.avif head.card-late image.unversioned image.transparent-declared images-differ.both-ok declared-size.missing image-alt.missing description.under-linkedin-min author.missing url.other-path site-name.missing title-description.identical ua-diff.crawler-richer
oktitle.ok description.ok image.loads image.absolute-https image.dimensions-ok aspect.own-layout aspect.ok crop.safe bytes.ok format.supported image.opaque declared-size.ok image-alt.ok type.ok url.ok site-name.ok card.own-layout card.ok ua-diff.same robots.image-allowed

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 warn or tip. Never at error.
  • Raising a check to error is 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:

next=$(curl -sS -D- -o og.json "https://api.poly.io/og?url=$URL" | grep -i '^x-og-spec-next:')
[ -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 does.

Talk to us Back to the tool