{
  "$comment": "Contract changes to the Open Graph preview API, newest first. The current check inventory is included so a caller can diff it against the ids their gate names. See https://www.poly.io/social-card-preview/api/#changes.",
  "api": "https://api.poly.io",
  "generated": "2026-10-29",
  "policy": {
    "newChecksLandAt": ["warn", "tip"],
    "breaking": [
      "a check id removed or renamed",
      "a check's level raised to error",
      "a response field removed or retyped",
      "an error code removed"
    ],
    "notBreaking": ["a new check at warn or tip", "a new response field"]
  },
  "current": {
    "checkCount": 87,
    "checks": [{ "id": "head.truncated", "level": "warn" }, { "id": "title.missing", "level": "error" }, { "id": "title.long", "level": "warn" }, { "id": "title.ok", "level": "ok" }, { "id": "title.twitter-only", "level": "warn" }, { "id": "description.missing", "level": "warn" }, { "id": "description.very-long", "level": "warn" }, { "id": "description.long", "level": "warn" }, { "id": "description.short", "level": "tip" }, { "id": "description.ok", "level": "ok" }, { "id": "description.twitter-only", "level": "warn" }, { "id": "description.meta-only", "level": "tip" }, { "id": "image.bad-scheme", "level": "error" }, { "id": "image.missing", "level": "error" }, { "id": "image.refused-allowlisted", "level": "warn" }, { "id": "image.refused-everywhere", "level": "error" }, { "id": "image.refused-unprobed", "level": "error" }, { "id": "image.http-error", "level": "error" }, { "id": "image.not-an-image", "level": "warn" }, { "id": "image.unreachable", "level": "warn" }, { "id": "image.unloadable", "level": "warn" }, { "id": "image.loads", "level": "ok" }, { "id": "image.insecure", "level": "warn" }, { "id": "image.relative", "level": "warn" }, { "id": "image.absolute-https", "level": "ok" }, { "id": "image.twitter-only", "level": "error" }, { "id": "image.too-small", "level": "warn" }, { "id": "image.below-1200", "level": "warn" }, { "id": "image.dimensions-ok", "level": "ok" }, { "id": "aspect.own-layout", "level": "ok" }, { "id": "aspect.off-target", "level": "warn" }, { "id": "aspect.ok", "level": "ok" }, { "id": "aspect.over-whatsapp-max", "level": "tip" }, { "id": "crop.safe", "level": "ok" }, { "id": "crop.detail-at-edge", "level": "tip" }, { "id": "crop.wide", "level": "tip" }, { "id": "bytes.over-x-limit", "level": "warn" }, { "id": "bytes.over-whatsapp", "level": "tip" }, { "id": "bytes.ok", "level": "ok" }, { "id": "format.webp", "level": "tip" }, { "id": "format.supported", "level": "ok" }, { "id": "format.avif", "level": "tip" }, { "id": "format.unsupported", "level": "error" }, { "id": "head.card-late", "level": "tip" }, { "id": "image.several", "level": "warn" }, { "id": "image.expiring", "level": "warn" }, { "id": "image.unversioned", "level": "tip" }, { "id": "image.slow", "level": "warn" }, { "id": "image.transparent-declared", "level": "tip" }, { "id": "image.transparent", "level": "warn" }, { "id": "image.opaque", "level": "ok" }, { "id": "images-differ.other-broken", "level": "warn" }, { "id": "images-differ.both-ok", "level": "tip" }, { "id": "declared-size.missing", "level": "tip" }, { "id": "declared-size.unusable", "level": "warn" }, { "id": "declared-size.mismatch", "level": "warn" }, { "id": "declared-size.ok", "level": "ok" }, { "id": "image-alt.missing", "level": "tip" }, { "id": "image-alt.ok", "level": "ok" }, { "id": "robots.noindex", "level": "warn" }, { "id": "description.under-linkedin-min", "level": "tip" }, { "id": "author.missing", "level": "tip" }, { "id": "type.missing", "level": "warn" }, { "id": "type.ok", "level": "ok" }, { "id": "url.missing", "level": "warn" }, { "id": "url.other-host", "level": "warn" }, { "id": "url.other-path", "level": "tip" }, { "id": "url.ok", "level": "ok" }, { "id": "site-name.missing", "level": "tip" }, { "id": "site-name.ok", "level": "ok" }, { "id": "title-description.identical", "level": "tip" }, { "id": "text.double-escaped", "level": "warn" }, { "id": "card.missing", "level": "warn" }, { "id": "card.summary-with-large-image", "level": "warn" }, { "id": "card.own-layout", "level": "ok" }, { "id": "card.ok", "level": "ok" }, { "id": "crawler.refused-unprobed", "level": "warn" }, { "id": "crawler.refused-everywhere", "level": "error" }, { "id": "crawler.refused-allowlisted", "level": "warn" }, { "id": "crawler.refused-partial", "level": "error" }, { "id": "ua-diff.crawler-starved", "level": "warn" }, { "id": "ua-diff.crawler-richer", "level": "tip" }, { "id": "ua-diff.same", "level": "ok" }, { "id": "meta-refresh", "level": "warn" }, { "id": "robots.image-blocked", "level": "warn" }, { "id": "robots.image-blocked-undocumented", "level": "warn" }, { "id": "robots.image-allowed", "level": "ok" }]
  },
  "changes": [
    { "date": "2026-10-25", "kind": "addition", "summary": "Repeat url to check up to five pages in one request", "note": "?url=a&url=b returns {ok, batch: true, count, results}: one result per url in the order asked, each the answer a single request for that page would give, with its own ok, code and checks, plus the url as sent. The links (source, spec, specVersion, specNext, quota) are given once, at the top. Each page counts as one request against the allowance. More than five is refused whole with the new code too_many_urls. A request with one url is answered exactly as before." },
    { "date": "2026-10-06", "kind": "addition", "summary": "?site= with a path plans that section: every page the sitemap lists under it", "note": "?site=example.com/blog/ used to drop the path and plan the whole site, one page per template. It now returns the pages the sitemap lists under /blog/: the plan holds up to cap of them, and a new under field carries the path, how many there are, up to 1,000 of their URLs, and whether the list is incomplete, so a caller can check only the pages it has not seen. ?site=example.com/* asks for the whole site's list the same way. A site given without a path, or with only \"/\", is planned exactly as before." },
    { "date": "2026-09-29", "kind": "fix", "summary": "Two messages stop naming causes and claims they cannot stand behind", "checks": ["image.unloadable","description.under-linkedin-min"], "note": "image.unloadable said the image was \"too large, blocked, or not an image\". Too large cannot cause it, because an oversized image is read as a slice, and not-an-image has its own id. It now names the refusal that happened: a hostname that does not resolve, a private, internal or reserved address, or a redirect to a host this API does not fetch.\n\ndescription.under-linkedin-min said LinkedIn is \"the one platform that publishes a minimum\". The 100-character warning is something LinkedIn's Post Inspector was observed to show, not a documented rule, so the message now says the Inspector warns and the card still renders in full. Ids and levels are unchanged." },
    { "date": "2026-09-27", "kind": "breaking", "summary": "From 27 September 2026, previews of TikTok, Instagram, Facebook, X and LinkedIn pages need an API key", "checks": [], "note": "This API checks the card your own site produces when someone shares it. Most of what reached it last week was something else: scripts previewing the big platforms' own pages in bulk, from a few addresses, none identified. That checks nobody's card, and an address that pulls a platform's pages all day risks being blocked by it, which would break previews of links to that platform for everyone else too.\n\nFrom 27 September 2026, a request for a page on tiktok.com, instagram.com, facebook.com, x.com, twitter.com or linkedin.com without an API key returns ok:false with code gated_host, the code already used for reddit.com. A short link that redirects onto one of those sites is refused at the redirect. The refusal costs nothing against the daily allowance. Requests with a key are unaffected; keys are free, issued by hand, from enquiries@poly.io.\n\nThis took effect on release rather than after notice. The risk it removes falls on every caller, and a notice period would have served only the traffic it is aimed at.\n\nNo check id changes and no field is removed. A caller branching on code already handles gated_host; one that assumed only reddit.com could produce it should widen that assumption." },
    { "date": "2026-09-27", "kind": "breaking", "summary": "ua= no longer sends a platform's crawler name to that platform's own servers", "checks": [], "note": "ua=facebook, x, linkedin, slack or discord fetches the page as that platform's crawler, which is how you see what its unfurl reads from your site. It now never does so against the platform's own site, short links or image CDN. A request whose url is on the named crawler's own site, or redirects onto it, returns ok:false with code gated_host. An og:image or robots.txt on the platform's CDN is fetched under this service's own user agent instead, so the image check still runs.\n\nThis took effect on release rather than after notice, because the thing it stops is the one most likely to get the service blocked for every caller. It affects only requests that asked a platform's crawler to fetch that platform's own pages; ua= against any other site is unchanged." },
    { "date": "2026-09-22", "kind": "fix", "summary": "Three checks stop asserting things about other platforms that we cannot point at", "checks": ["bytes.over-x-limit","bytes.over-whatsapp","image.insecure"], "note": "platform-claims.mjs exists so every statement about somebody else's product carries a source and a date it was read, and a test fails when nobody has looked in six months. What nothing checked was the other direction: a check MESSAGE could assert anything about any platform and no test noticed. Three had drifted past their sources.\n\nbytes.over-x-limit said \"X drops images over 5 MB\". No claim carries that number and X's Cards documentation is withdrawn, so it cannot be pointed at. The threshold is unchanged at 5 MB; the message now cites Facebook's documented 8 MB ceiling and WhatsApp's 600 KB, and says plainly that X's figure is no longer citable. The id keeps its name because an id names a condition and renaming one breaks every caller keying on it.\n\nimage.insecure said \"Facebook/LinkedIn require https or drop the image\". Facebook's image guidance was re-read on the date above and does not mention the scheme either way; LinkedIn's was not read at all. It now describes the mixed-content mechanism, which is true and ours to state, and attributes no rule to either company. bytes.over-whatsapp likewise no longer refers to \"X's limits\".\n\nTwo claims are added rather than removed: Facebook's image limits (8 MB, 200x200 minimum, 1200x630 recommended, 600x315 floor), read through a browser because that page answers 400 to curl; and LinkedIn's Post Inspector, which is where description.under-linkedin-min and author.missing came from and which had been cited in prose without being tracked.\n\nNo id is removed and nothing changes level, so an error-level gate is unaffected." },
    { "date": "2026-09-22", "kind": "fix", "summary": "The crop checks no longer contradict the aspect check", "checks": ["crop.detail-at-edge","crop.wide","crop.safe"], "note": "A 1.91:1 card was getting both \"Aspect ratio 1.90:1 suits a summary_large_image card\" at ok AND \"Compact previews crop to ~1:1, keep the focal point centred\" at tip. A tick and a telling-off about one image, with nothing saying they described different renderings.\n\nThe one ~1:1 claim with a source under it is X's plain summary card. A page declaring summary_large_image has opted out of that card, so the advice was for a crop X will not perform. The crop checks now fire only where that crop applies — a summary card, or no twitter:card at all — and each message names the card it is describing. Other clients do render compact previews; not one publishes a ratio we can cite, and \"compact previews\" was that unsourced generalisation wearing X's number.\n\nRewording it surfaced a second fault. player and app cards publish no crop, and a test said so — but the test matched /crops to/ while the message said \"crop to\", so the rule it described was never enforced and a player card had been getting crop advice for a crop it does not have. The condition now says what the test always claimed.\n\nNothing changes level and no id is removed, so a gate on \"no checks at level error\" is unaffected. A caller counting tips will see fewer on wide cards, which is the point." },
    { "date": "2026-09-22", "kind": "addition", "summary": "Four checks: two from LinkedIn's own Post Inspector, plus noindex and card depth", "checks": ["description.under-linkedin-min","author.missing","robots.noindex","head.card-late"], "note": "This API does not state other people's limits, because they are other people's to change. Two of these are the exception and they earn it: LinkedIn PUBLISHES them, in its own debugging tool, on the page you land on when a card goes wrong. It says \"The description should be at least 100 characters long\" and it says \"No author found\" for a page whose author is in JSON-LD, because it reads <meta name=\"author\"> and nothing else. Both land at tip: a card LinkedIn grumbles about still renders, and the strip that prompted this had both warnings and a perfectly good preview.\n\nrobots.noindex reports a page asking crawlers to stay away, from either the meta tag or an X-Robots-Tag header — the header being the interesting half, since it is set by the server and never appears in the markup you are reading. It is one verdict naming whichever source carried it, and matched as a word in a list: content=\"noindex,nofollow\" is not equal to \"noindex\", and an exact match is how two deliberately-unindexed pages slipped into our own dogfood test unnoticed.\n\nhead.card-late reports how deep the card block sits, in bytes, because a crawler is not obliged to read a whole document. It is deliberately loose at 4 KB. An earlier draft set it at 1 KB and fired on six of eight real sites while describing no failure anyone had seen — and LinkedIn's inspector parsed one of our pages perfectly from a 206 partial response, so a tag at 1.5 KB is demonstrably fine. github.com carries its card 25 KB in; that is the shape this is for." },
    { "date": "2026-09-21", "kind": "addition", "summary": "Four new checks on the image: duplicate tags, an unversioned URL, a signed URL, and a slow download", "checks": ["image.several","image.unversioned","image.expiring","image.slow"], "note": "All four share a shape with the transparency checks below: none of them is visible to the person approving the card. Two live in the URL and bite later — image.expiring when the signature lapses, image.unversioned when the picture is next replaced and platforms keep serving the cached one. image.several is a tag a browser never renders: several og:image tags, which platforms resolve differently, so which picture appears is not something the page decides. image.slow is a measurement we were already taking and discarding.\n\nWhat they deliberately do NOT do is name a platform. We cannot source which og:image any given crawler picks, or what timeout it fetches on, so image.several reports that the outcome is unpredictable and image.slow reports the seconds — neither claims a limit. image.unversioned is generous about what counts as a version (any query string, any digit in the path) because a false accusation on every hand-named card would get the check switched off, while a missed one costs nobody anything." },
    { "date": "2026-09-21", "kind": "addition", "summary": "Three new checks on image transparency", "checks": ["image.transparent","image.transparent-declared","image.opaque"], "note": "Open Graph has no light and dark variants of og:image: one file is served to every viewer, and the platform composites it onto its own background — white on a light client, near-black on a dark one. A card on a transparent ground therefore reads for half the audience and disappears for the other half, and whoever approved it saw only their own half. image.transparent fires at warn when more than 1% of the pixels are see-through; under that it is an antialiased edge, not a background, and image.opaque passes.\n\nWhat the check does NOT do is read the PNG colour-type byte, which says an alpha channel is present and nothing about whether it is used. Eight of this site's own card images are RGBA with every pixel opaque, because the exporter wrote RGBA by default; a check keyed on the header would have flagged all eight and been wrong eight times. The header only decides whether a decode is worth paying for. For WebP and GIF, where the pixels are not decoded here, image.transparent-declared says so at tip rather than pretending to a verdict." },
    { "date": "2026-09-20", "kind": "addition", "summary": "Responses carry titleLength and descriptionLength", "note": "Both numbers were already measured — they were only ever printed inside the English of the title.long and description.long checks, so a caller wanting a different budget had to match prose that gets reworded. They are now fields. Nothing is removed or retyped, and no check changed level, so a gate on \"no checks at level error\" is unaffected.\n\nWhy a number and not a verdict: how much of a description a card actually shows depends on a font and a width this API never sees. description.long fires at 200 because that is the one limit with a primary source, and a two-line card commonly cuts well before it. Pick the budget your own design needs and gate on that." },
    { "date": "2026-09-19", "kind": "fix", "summary": "The burst limit is 10/s (was 3/s), and a saturated compute layer no longer answers 503 on ordinary traffic", "note": "0.82% of requests over the previous fortnight — one in 122 — were answered with a bare 503 because reserved concurrency was reached every single day. That is now 5x higher and a 25-request burst returns 25 cards. The edge burst limit went from 3/s to 10/s, so an ordinary CI sweep of twenty URLs no longer trips it. Both /llms.txt and the reference now state plainly that 429 and 503 can arrive from in front of the API and carry no ok field; the previous wording attributed the 503 to the burst limit, which was the wrong limiter." },
    { "date": "2026-09-19", "kind": "addition", "summary": "Every response carries an x-og-identify header saying what identifying yourself is worth", "fields": ["x-og-identify"], "note": "A header, not a body field, so it survives the shared five-minute cache — it is a constant of the deployment, and per-caller numbers still never ride on a cached response. It exists because the upgrade path was only ever explained on the 429, which nobody reaches: 291 callers in a week, none identified. Nothing is required of a caller who ignores it." },
    { "date": "2026-09-17", "kind": "addition", "summary": "?fresh=1 bypasses the five-minute cache, and every response carries a spec pointer", "fields": ["fresh","spec"], "note": "For the deploy-check-fix-redeploy loop, where the second check landed inside the cache window and reported the state before the fix. `spec` points at /llms.txt — the machine twin of the HTML reference." },
    { "date": "2026-09-17", "kind": "addition", "summary": "This log, and the promise underneath it", "note": "New checks now land at warn or tip and may not arrive at error; raising an existing check's level counts as breaking and is announced here before it ships. Published because a caller gating on level had no way to see such a change coming." }
  ]
}
