{
  "openapi": "3.1.0",
  "info": {
    "title": "Polymorphism Open Graph preview API",
    "summary": "Reads any URL's Open Graph and Twitter card tags, fetches and measures the image, and returns a checklist of what is wrong.",
    "description": "Fetches every page twice — once as a browser, once as a crawler that identifies itself — and reports where they differ, which is the failure an author cannot see for themselves. Always answers HTTP 200; check the `ok` field, not the status code. Free, no key, CORS-enabled.",
    "version": "1.7.0",
    "contact": { "name": "Polymorphism Ltd", "email": "enquiries@poly.io", "url": "https://www.poly.io/social-card-preview/api/" },
    "license": { "name": "Free for any use within the published allowance" }
  },
  "servers": [{ "url": "https://api.poly.io" }],
  "paths": {
    "/og": {
      "get": {
        "operationId": "previewCard",
        "summary": "Inspect one URL's social card, up to five in one request, or plan a scan of a whole site",
        "parameters": [
          { "name": "url", "in": "query", "required": false,
            "description": "The page to inspect, http or https. URL-encode it. Required unless site is given. Repeat it, up to 5 times, to check several pages in one request: the answer is then a batch, with one result per url in the order asked.",
            "schema": { "type": "string" } },
          { "name": "site", "in": "query", "required": false,
            "description": "An origin instead of url. Reads the sitemap, clusters URLs by shape and returns a plan of one representative page per template. With a path (example.com/blog/), or /* for the whole site, the plan is the sitemap's pages under it, and under.urls lists up to 1,000 of them.",
            "schema": { "type": "string" } },
          { "name": "cap", "in": "query", "required": false,
            "description": "With site, the longest plan to return. Clamped 1–100, default 40.",
            "schema": { "type": "integer" } },
          { "name": "ua", "in": "query", "required": false,
            "description": "Fetch as a named platform crawler: facebook, x, linkedin, slack, discord or browser. Defaults to our own crawler.",
            "schema": { "type": "string" } },
          { "name": "fresh", "in": "query", "required": false,
            "description": "Set to 1 to bypass the five-minute cache. For the deploy-check-fix-recheck loop, where the re-check otherwise lands inside the window and reports the state before your fix. Costs a quota unit like any request.",
            "schema": { "type": "string" } },
          { "name": "contact", "in": "query", "required": false,
            "description": "An email or URL identifying you; raises the daily allowance. The x-og-contact header is preferred.",
            "schema": { "type": "string" } },
          { "name": "key", "in": "query", "required": false,
            "description": "An API key. The x-og-key header is preferred — a key in a query string ends up in logs and Referer headers.",
            "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Always 200. `ok` is false for every failure, with a `code` and a `retryable` verdict.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Response" } } }
          }
        }
      }
    },
    "/og/quota": {
      "get": {
        "operationId": "checkQuota",
        "summary": "Today's allowance for whoever is asking — does not itself count against it",
        "responses": { "200": { "description": "Current usage", "content": { "application/json": {
          "schema": { "type": "object", "properties": {
            "ok": { "type": "boolean" }, "limit": { "type": "integer" }, "used": { "type": "integer" },
            "remaining": { "type": "integer" }, "resetSeconds": { "type": "integer" },
            "identified": { "type": "boolean" }, "exact": { "type": "boolean" },
            "upgrade": { "type": ["string", "null"] } } } } } } }
      }
    }
  },
  "components": {
    "schemas": {
      "Response": {
        "type": "object",
        "required": ["ok", "source"],
        "properties": {
          "ok": { "type": "boolean" },
          "error": { "type": "string", "description": "Present when ok is false." },
          "code": { "type": "string", "enum": ["no_url", "bad_url", "bad_scheme", "bad_ua", "bad_key", "unknown_key", "revoked_key", "bad_site", "no_sitemap", "empty_sitemap", "other_origin", "blocked_address", "host_not_found", "gated_host", "not_html", "target_http_error", "target_unreachable", "target_too_large", "target_failed", "quota_exceeded", "too_many_urls"] },
          "retryable": { "type": "boolean", "description": "Present only on failures. False means the request will never succeed as written — do not retry it." },
          "status": { "type": ["integer", "null"], "description": "The target's HTTP status, when there was one." },
          "finalUrl": { "type": "string" },
          "host": { "type": "string" },
          "title": { "type": "string" },
          "description": { "type": "string" },
          "titleLength": { "type": "integer", "description": "Length of title in glyphs. Reported so you can gate on your own budget; how much a card shows depends on a width and a font this API cannot see." },
          "descriptionLength": { "type": "integer", "description": "Length of description in glyphs. The description.long check fires at 200, the one figure with a primary source — a two-line card usually cuts well before that." },
          "siteName": { "type": "string" },
          "card": { "type": "string" },
          "imageUrl": { "type": "string" },
          "imageOk": { "type": "boolean" },
          "imageWidth": { "type": ["integer", "null"] },
          "imageHeight": { "type": ["integer", "null"] },
          "otherImage": { "type": ["object", "null"], "description": "The other namespace's image when og:image and twitter:image differ — both are fetched and measured." },
          "views": { "type": "array", "description": "What each platform resolves, grouped by the answer.",
            "items": { "$ref": "#/components/schemas/View" } },
          "uaDiff": { "type": ["array", "null"], "description": "Fields where the crawler and the browser were served different values." },
          "robotsBlocked": { "type": ["string", "null"] },
          "crawlerRefused": { "type": ["object", "null"] },
          "namedCrawlers": { "type": ["array", "null"], "description": "Set only when our crawler was refused: the result of asking again as Facebook's and X's." },
          "checks": { "type": "array", "items": { "$ref": "#/components/schemas/Check" } },
          "source": { "type": "string", "description": "The human reference for this API." },
          "specVersion": { "type": "string", "description": "The contract version this response was produced by. Log it; do not pin it — an ordinary release would fail your build." },
          "specNext": { "type": ["string", "null"], "description": "Set when a breaking change is scheduled: the version and the date it lands. Surface it as a warning. Null otherwise." },
          "spec": { "type": "string", "description": "The machine-readable reference: error codes, retryability, and a whole example response. Fetch this rather than the HTML." },
          "fresh": { "type": "boolean", "description": "Present only when ?fresh=1 was asked for and the cache was bypassed." },
          "quota": { "type": "string" },
          "batch": { "type": "boolean", "description": "Present, true, when url was repeated. The pages are then in results, and this object carries only ok, batch, count and the links." },
          "count": { "type": "integer", "description": "With batch: how many results there are, one per url asked." },
          "results": { "type": "array", "description": "With batch: one entry per url in the order asked, each the answer a single request for it would give, plus the url as asked, with the links (source, spec, specVersion, specNext, quota) given once at the top instead. Each has its own ok.",
            "items": { "$ref": "#/components/schemas/Response" } }
        }
      },
      "View": {
        "type": "object",
        "properties": {
          "platforms": { "type": "array", "items": { "type": "string" } },
          "reads": { "type": "string", "description": "Which namespace chain this group follows." },
          "title": { "type": "string" }, "description": { "type": "string" },
          "imageUrl": { "type": "string" }, "imageOk": { "type": "boolean" },
          "width": { "type": ["integer", "null"] }, "height": { "type": ["integer", "null"] },
          "uncertain": { "type": ["array", "null"], "items": { "type": "string" },
            "description": "Fields whose value this platform has not published a precedence for. Set for Slack, which documents reading both namespaces without saying which wins." }
        }
      },
      "Check": {
        "type": "object",
        "required": ["level", "id", "message"],
        "properties": {
          "level": { "type": "string", "enum": ["ok", "tip", "warn", "error"] },
          "id": { "type": "string", "description": "Stable. Key off this, never off message — the wording is rewritten whenever a clearer or more accurate one is found.",
            "enum": ["head.truncated", "title.missing", "title.long", "title.ok", "title.twitter-only", "description.missing", "description.very-long", "description.long", "description.short", "description.ok", "description.twitter-only", "description.meta-only", "image.bad-scheme", "image.missing", "image.refused-allowlisted", "image.refused-everywhere", "image.refused-unprobed", "image.http-error", "image.not-an-image", "image.unreachable", "image.unloadable", "image.loads", "image.insecure", "image.relative", "image.absolute-https", "image.twitter-only", "image.too-small", "image.below-1200", "image.dimensions-ok", "aspect.own-layout", "aspect.off-target", "aspect.ok", "aspect.over-whatsapp-max", "crop.safe", "crop.detail-at-edge", "crop.wide", "bytes.over-x-limit", "bytes.over-whatsapp", "bytes.ok", "format.webp", "format.supported", "format.avif", "format.unsupported", "head.card-late", "image.several", "image.expiring", "image.unversioned", "image.slow", "image.transparent-declared", "image.transparent", "image.opaque", "images-differ.other-broken", "images-differ.both-ok", "declared-size.missing", "declared-size.unusable", "declared-size.mismatch", "declared-size.ok", "image-alt.missing", "image-alt.ok", "robots.noindex", "description.under-linkedin-min", "author.missing", "type.missing", "type.ok", "url.missing", "url.other-host", "url.other-path", "url.ok", "site-name.missing", "site-name.ok", "title-description.identical", "text.double-escaped", "card.missing", "card.summary-with-large-image", "card.own-layout", "card.ok", "crawler.refused-unprobed", "crawler.refused-everywhere", "crawler.refused-allowlisted", "crawler.refused-partial", "ua-diff.crawler-starved", "ua-diff.crawler-richer", "ua-diff.same", "meta-refresh", "robots.image-blocked", "robots.image-blocked-undocumented", "robots.image-allowed"] },
          "message": { "type": "string" }
        }
      }
    }
  },
  "x-error-codes": [
    { "code": "no_url", "retryable": false, "description": "No url (or site) parameter was given." },
    { "code": "bad_url", "retryable": false, "description": "The url could not be parsed." },
    { "code": "bad_scheme", "retryable": false, "description": "The url is not http or https." },
    { "code": "bad_ua", "retryable": false, "description": "The ua parameter is not one of the supported crawler names." },
    { "code": "bad_key", "retryable": false, "description": "The key is not shaped like an API key." },
    { "code": "unknown_key", "retryable": false, "description": "The key is well-formed but not recognised." },
    { "code": "revoked_key", "retryable": false, "description": "The key was recognised and has been revoked." },
    { "code": "bad_site", "retryable": false, "description": "The site parameter could not be parsed as an origin." },
    { "code": "no_sitemap", "retryable": false, "description": "No sitemap was found — neither a Sitemap: line in robots.txt nor /sitemap.xml answered." },
    { "code": "empty_sitemap", "retryable": false, "description": "A sitemap was found but held no usable URLs." },
    { "code": "other_origin", "retryable": false, "description": "The sitemap lists pages on a different host; the error names which one to scan instead." },
    { "code": "blocked_address", "retryable": false, "description": "The URL resolves to an address this API refuses on principle (loopback, link-local, private ranges). Permanent." },
    { "code": "host_not_found", "retryable": false, "description": "The hostname does not resolve. Usually a typo — distinct from an address we refuse." },
    { "code": "gated_host", "retryable": false, "description": "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." },
    { "code": "not_html", "retryable": false, "description": "The URL returned something that is not HTML — an image or a PDF, say." },
    { "code": "target_http_error", "retryable": false, "description": "The page returned an HTTP error. Retryable only on 5xx; the status is in the response." },
    { "code": "target_unreachable", "retryable": true, "description": "The page could not be reached — a timeout or a refused connection." },
    { "code": "target_too_large", "retryable": false, "description": "The page exceeded the fetch ceiling." },
    { "code": "target_failed", "retryable": false, "description": "The fetch failed for a reason with no more specific code." },
    { "code": "quota_exceeded", "retryable": true, "description": "The daily allowance is spent. Resets on the UTC day; see the quota endpoint." },
    { "code": "too_many_urls", "retryable": false, "description": "The request repeats url more times than one request may. Nothing was checked; split the pages across several requests." }
  ]
}
