API reference
Base URL: https://YOUR-UNFURLR-HOST. All responses are JSON. Machine-readable spec: /openapi.json · interactive explorer: /docs/try.
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.
| Param | In | Description |
|---|---|---|
url | query | Required. http(s) URL, max 2048 chars. |
fresh | query | Optional 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"const r = await fetch("https://YOUR-UNFURLR-HOST/v1/metadata?url=" + encodeURIComponent(url), {
headers: { Authorization: `Bearer ${KEY}` }
});
const data = await r.json();import requests
r = requests.get("https://YOUR-UNFURLR-HOST/v1/metadata", params={"url": url},
headers={"Authorization": f"Bearer {KEY}"}, timeout=15)
data = r.json()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
}
| Field | Meaning |
|---|---|
open_graph, twitter | Raw tags with the og: / twitter: prefix removed (e.g. "card", not "twitter:card"). |
description_source, site_name_source | Where 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_fallback | images_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_guess | true when the page declared no icon and favicon is an unverified /favicon.ico guess. |
icon_large | apple-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. |
author | Present when oEmbed provides an author name. |
cached | true 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:
| Status | Meaning |
|---|---|
| 400 | Bad request (e.g. invalid URL, blocked private/internal address) |
| 401 | Missing or invalid API key |
| 403 | Forbidden. site_opted_out means the site's owner asked us not to fetch it (see /bot); refunded. |
| 409 | Conflict (e.g. email already has a key) |
| 422 | Validation error, non-HTML content, or too many batch URLs |
| 429 | Rate or monthly limit reached (rate_limited, quota_exceeded, publishable_quota_exceeded); see Retry-After |
| 451 | robots_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. |
| 200 | A 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. |
| 502 | Upstream 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. |
| 504 | Upstream timeout |
Questions or something missing? Email smaran+unfurlr@mail.grokbot.com.