API reference

Base URL: https://YOUR-UNFURLR-HOST. All responses are JSON. Machine-readable spec: /openapi.json · interactive explorer: /docs/try.

AuthGET /v1/metadataPOST /v1/metadata/batchGET /v1/linktoolGET /v1/usagePOST /v1/keys/rotateGET /v1/recentDashboardPOST /v1/signupGET /v1/plansGET /v1/demoErrors

Authentication

Send your key as Authorization: Bearer $KEY or X-API-Key: $KEY. Get a free key on the home page (500 requests/month). Quota headers on metered responses: X-Quota-Limit, X-Quota-Used, X-Quota-Remaining; Retry-After on 429.

GET /v1/metadata

Fetch one URL and return its metadata. Results are cached for 24h; one URL = one request, failed fetches are refunded.

ParamInDescription
urlqueryRequired. http(s) URL, max 2048 chars.
freshqueryOptional bool. Bypass the 24h cache (still counts as a request).
curl "https://YOUR-UNFURLR-HOST/v1/metadata?url=https://github.com" \
  -H "Authorization: Bearer $KEY"

Response (fields may be null when the page doesn't provide them):

{
  "url": "https://github.com/",
  "title": "GitHub · Change is constant. GitHub keeps you ahead. · GitHub",
  "description": "Join the world's most widely adopted, AI-powered developer platform…",
  "site_name": "GitHub",
  "canonical": "https://github.com",
  "language": "en",
  "favicon": "https://github.githubassets.com/favicons/favicon.png",
  "images": ["https://images.ctfassets.net/…/GH-Homepage-Universe-img.png"],
  "open_graph": { "title": "…", "image": "…", "site_name": "GitHub" },
  "twitter": { "card": "summary_large_image", "site": "@github" },
  "oembed": null,
  "favicon_guess": false,
  "icon_large": null,
  "images_source": "og",
  "description_source": "og",
  "site_name_source": "og",
  "cached": false
}
FieldMeaning
open_graph, twitterRaw tags with the og: / twitter: prefix removed (e.g. "card", not "twitter:card").
description_source, site_name_sourceWhere the value came from: og, meta, twitter, jsonld, page, oembed, title, host. Use it to judge quality (e.g. host means the site name is just the hostname). null when the field is empty.
images_source, images_fallbackimages_source is og (og:/twitter: image tags), oembed, or fallback when the page has no share image, else null. For fallback, images_fallback says which step found it, tried in order: image_src, jsonld, itemprop, page (first sizable image), logo, and last icon (apple-touch-icon or largest icon). Skip fallback images if you only want real share cards.
favicon_guesstrue when the page declared no icon and favicon is an unverified /favicon.ico guess.
icon_largeapple-touch-icon URL, else the largest declared icon (96 px or more), else an SVG icon, when the page declares one, else null.
oembed{endpoint, format, provider, data, error?} or null. YouTube, Vimeo, X and Spotify use their official endpoints. For X posts, data.synthesized: true means we built the standard embed blockquote from the post URL without contacting X (X blocks bots and its pages are JS-only), so there is no post text.
authorPresent when oEmbed provides an author name.
cachedtrue when served from the 24h cache.

POST /v1/metadata/batch

Up to 10 URLs per call; each URL counts as one request, failed items are refunded. Body: {"urls": [...], "fresh": false}. Returns {"results": [...]} in input order; a failed item has status and error instead of metadata.

curl -X POST https://YOUR-UNFURLR-HOST/v1/metadata/batch -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"urls":["https://a.com","https://b.com"]}'

GET /v1/linktool

Editor.js LinkTool-compatible. Always HTTP 200 with {"success": 1, "link", "meta": {"title","description","image":{"url"}}} or {"success": 0, "error"}. Auth via X-API-Key with your secret key from a backend, or ?key= with a publishable pk_ key. Secret keys in a URL are refused (query_key_not_allowed).

// Editor.js config (browser): publishable key, locked to your origins
linkTool: { class: LinkTool, config: { endpoint: "https://YOUR-UNFURLR-HOST/v1/linktool?key=pk_YOUR_PUBLISHABLE_KEY" } }

POST /v1/keys/publishable

Creates (or replaces) a browser-only pk_ key; DELETE revokes it. Set an origin allowlist first (PUT /v1/keys/origins), otherwise 400 origins_required. A pk_ key works only on /v1/linktool and /v1/metadata, only when the request's Origin (or Referer) matches the allowlist, and has its own small quota (100/month, 5/min) separate from your 500. It is refused on usage, recent, keys and batch endpoints (publishable_key_not_allowed). If it leaks, only that small quota is exposed; replace it with a new POST.

curl -X PUT https://YOUR-UNFURLR-HOST/v1/keys/origins -H "X-API-Key: $KEY" -d '{"origins":["https://app.example.com"]}'
curl -X POST https://YOUR-UNFURLR-HOST/v1/keys/publishable -H "X-API-Key: $KEY"

oEmbed HTML: oembed.html is third-party markup. Render it only inside a sandboxed iframe (e.g. <iframe sandbox="allow-scripts allow-popups" srcdoc="…">, without allow-same-origin), never via innerHTML.

GET /v1/usage

Your tier, current UTC month, used, limit, remaining, per_minute, and publishable: {"used", "limit": 100, "per_minute": 5, "prefix"} for calls made with your pk_ key. Limited to 30 calls/min.

curl https://YOUR-UNFURLR-HOST/v1/usage -H "X-API-Key: $KEY"

POST /v1/keys/rotate

Send your current key; get a new one back. The old key is revoked immediately; usage and quota carry over. Response: {"api_key", "tier", "revoked_prefix"}. Limited to 5 rotations per hour per IP. Lost your key entirely? Email us and we'll reissue it.

curl -X POST https://YOUR-UNFURLR-HOST/v1/keys/rotate -H "X-API-Key: $KEY"

GET /v1/recent

Your last 20 requested URLs: {"calls": [{"at", "url", "status"}], "keep": 20}, newest first (at is UTC ISO 8601). Stored only so you can see them on the dashboard; doesn't count against your quota.

curl https://YOUR-UNFURLR-HOST/v1/recent -H "X-API-Key: $KEY"

Dashboard

/dashboard shows your usage, your last 20 calls, copy-paste snippets and a key-rotate button. Your key stays in your browser's localStorage and is only sent to this API.

POST /v1/signup

Self-serve free key. Body: {"email": "you@example.com"}. Returns api_key once (only its hash is stored). One key per email; rate-limited per IP.

GET /v1/plans

Currently available plans (free launch: 500 requests/month). No auth.

GET /v1/demo

Key-free demo used by the home page's Try-it box. Strict per-IP hourly limit; not for production use.

Errors

Errors return {"error": {"code", "message", "url"?}} with an HTTP status:

StatusMeaning
400Bad request (e.g. invalid URL, blocked private/internal address)
401Missing or invalid API key
403Forbidden. site_opted_out means the site's owner asked us not to fetch it (see /bot); refunded.
409Conflict (e.g. email already has a key)
422Validation error, non-HTML content, or too many batch URLs
429Rate or monthly limit reached (rate_limited, quota_exceeded, publishable_quota_exceeded); see Retry-After
451robots_disallowed (key-free demo, /check and batch): the site's robots.txt disallows unfurlr-bot. Policy: anonymous, key-free tools and bulk batch requests honour robots.txt; a keyed single-URL preview (metadata, linktool) is one fetch the user asked for, like pasting a link into a chat app, so it does not. That is why a site can show 451 in the free demo yet work with a key.
200A 200 card can carry warning: blocked_by_site (the site refused our bot; only host, favicon and any oEmbed data are filled) or no_metadata_found (page has no title, description or image). Check it before trusting the card.
502Upstream site error or fetch failure. site_blocked means the site serves bot walls, challenge pages or 401/403 to preview bots (e.g. X, Reddit, Meta). Refunded, never cached.
504Upstream timeout

Questions or something missing? Email smaran+unfurlr@mail.grokbot.com.