Metadata API
Link previews and Open Graph from any URL
Everything a link preview needs, in one predictable shape — however the page chose to declare it.
By Roger Campos · Last updated: September 2026
TL;DR
POST a URL to /meta and get one JSON object with the same keys on every page: title, description, site name, kind of page, language, authors, publication and update dates, share image with its size, favicon, icons, logo, feeds, keywords, canonical URL, robots, hreflang alternates, oEmbed endpoint and the raw Open Graph and Twitter tags. URLs come back absolute, and tags injected by JavaScript are read too. 1 credit per URL.
Free plan, no credit card. 1,000 credits a month.
Try it now — no signup
Try your own URL
Paste up to 10 URLs
Your result will appear here.
Pick one of the pages above to get started.
The request
One POST, one bearer token
curl -X POST https://urlpipe.dev/meta \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/blog/launch-week", "sync": true}'import requests
res = requests.post(
"https://urlpipe.dev/meta",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"url": "https://example.com/blog/launch-week", "sync": True},
)const res = await fetch("https://urlpipe.dev/meta", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://example.com/blog/launch-week", sync: true }),
})$ch = curl_init("https://urlpipe.dev/meta");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer YOUR_API_KEY",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["url" => "https://example.com/blog/launch-week", "sync" => true]),
]);
$response = curl_exec($ch);{
"final_url": "https://example.com/blog/launch-week",
"canonical_url": "https://example.com/blog/launch-week",
"title": "Everything we shipped in launch week",
"description": "Five days, five releases: what's new and why it matters.",
"site_name": "Example Blog",
"type": "article",
"language": "en",
"locale": "en-US",
"authors": [
{
"name": "Jane Doe",
"url": "https://example.com/authors/jane",
"profiles": [
"https://x.com/janedoe"
]
}
],
"published_at": "2026-09-01T08:00:00+00:00",
"modified_at": "2026-09-03T14:30:00+00:00",
"image": {
"url": "https://example.com/images/launch-week.jpg",
"width": 1200,
"height": 630,
"alt": "The team on stage",
"type": "image/jpeg"
},
"video": null,
"favicon_url": "https://example.com/favicon.ico",
"icons": [
{
"url": "https://example.com/favicon.ico",
"rel": "icon",
"sizes": "32x32",
"type": null
},
{
"url": "https://example.com/apple-touch-icon.png",
"rel": "apple-touch-icon",
"sizes": "180x180",
"type": null
}
],
"logo_url": "https://example.com/logo.svg",
"feeds": [
{
"url": "https://example.com/feed.xml",
"type": "application/rss+xml",
"title": "Example Blog"
}
],
"keywords": [
"launch",
"product"
],
"theme_color": "#0f172a",
"generator": "WordPress 6.6",
"manifest_url": "https://example.com/site.webmanifest",
"robots": {
"index": true,
"follow": true
},
"open_graph": {
"title": "Everything we shipped in launch week",
"description": "Five days, five releases.",
"image": "https://example.com/images/launch-week.jpg",
"url": "https://example.com/blog/launch-week",
"type": "article",
"site_name": "Example Blog",
"locale": "en_US"
},
"twitter": {
"card": "summary_large_image",
"site": "@example",
"creator": "@janedoe",
"title": null,
"description": null,
"image": null
},
"alternates": [
{
"hreflang": "es",
"url": "https://example.com/es/blog/launch-week"
}
],
"oembed_url": "https://example.com/wp-json/oembed/1.0/embed?url=https%3A%2F%2Fexample.com%2Fblog%2Flaunch-week",
"structured_data_types": [
"BlogPosting",
"Organization"
]
}The response
The fields you get
Every key is always present. A value the page doesn't declare is null, or an empty list — never a guess, never an invented URL — so your code handles one shape.
| Field | What it is |
|---|---|
| title | The page's own title, without the site name around it |
| description | The page description |
| site_name | The name of the site the page belongs to |
| type | The kind of page: article, website, product, video, profile, recipe… |
| language · locale | ISO 639-1 code (en), and the most specific tag declared for it (en-US) |
| authors | Each author's name, their page and their other profiles |
| published_at · modified_at | ISO 8601 dates of first publication and last update |
| image · video | The share image and video, with the size and type the page declares |
| favicon_url · icons · logo_url | The tab icon, every declared icon, and the site's logo |
| feeds | RSS, Atom and JSON feeds, with their titles |
| keywords | Keywords and tags from meta tags, article tags and structured data |
| canonical_url · alternates | The canonical URL, and the page in other languages |
| open_graph · twitter | The raw og: and twitter: tags, exactly as declared |
| robots · oembed_url · manifest_url · theme_color · generator · structured_data_types | Crawl directives, embed endpoint, app manifest, theme colour, CMS and schema.org types |
Reconciliation
When the page says it three different ways
Real pages declare the same thing in several places with different values — an og:title written for social, a <title> with the site name, an <h1>. /meta takes the first source in a fixed order that the page actually provides:
| Field | Order of precedence |
|---|---|
| title | og:title → twitter:title → <title> → <h1>, with the site's name stripped |
| description | meta description → og:description → twitter:description → JSON-LD |
| language | JSON-LD inLanguage → <html lang> → og:locale → the text itself |
| image | og:image → twitter:image → JSON-LD image |
| authors | JSON-LD author → microdata → meta author → rel=author → the byline |
| published_at | JSON-LD datePublished → article:published_time → citation and Dublin Core dates |
| favicon_url | rel=icon or shortcut icon → alternate icon → apple-touch-icon |
Relative URLs are resolved against the page — a page that declares /favicon.ico comes back with a full URL you can use. No model is involved: the page's own declarations are read out of the rendered DOM, so the same page always gives the same answer, at 1 credit.
Rendering
Tags that only exist after JavaScript
Single-page apps often set their title and Open Graph tags from script — React Helmet, Vue Meta, Next.js client navigation. A metadata API that reads the raw HTML sees the app shell's defaults, or nothing. /meta reads the page after it has rendered in headless Chrome, so script-injected tags are there. Note that most social networks' own unfurlers don't run JavaScript, so if the tags only appear after rendering, your links may still preview badly on those sites — the Open Graph guide covers how to check.
Fallback
When there is no og:image
Plenty of pages have no preview image. Ask for a screenshot in the same visit with /scrape, and use it when image is null:
import requests
res = requests.post(
"https://urlpipe.dev/scrape",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"url": url,
"operations": ["meta", "screenshot"],
"screenshot_options": {"full_page": False, "format": "webp"},
"sync": True,
},
)
ops = res.json()["operations"]
meta = ops["meta"]["result"]
image = (meta["image"] or {}).get("url") # or fall back to the screenshot's Base64 in ops["screenshot"]["result"]The screenshot adds 1 credit. For a hosted link rather than Base64, call /screenshot on its own and read its X-Result-Url header: a no-key image URL, valid for 30 days, that goes straight into an <img>.
In practice
From response to preview card
A link preview needs four things: a title, a line of description, an image and a small icon. The response maps onto them directly, with sensible fallbacks when a field is null:
const meta = await res.json()
const card = {
title: meta.title ?? new URL(url).hostname,
site: meta.site_name,
description: meta.description ?? "",
image: meta.image?.url, // null → use a screenshot, see above
icon: meta.favicon_url,
byline: [meta.authors[0]?.name, meta.published_at?.slice(0, 10)].filter(Boolean).join(" · "),
}Because every URL field comes back absolute, the card works wherever you render it — no need to remember the page URL and resolve /favicon.ico yourself. image.width and image.height let you reserve the space before the image loads, language tells you which way to set text direction or whether to translate, and feeds lets a reader app offer to subscribe.
At scale
Unfurling at volume
Link previews are read far more often than pages change. Results are stored and reused for 7 days by default, up to 30 with max_age, and a reused result is free — so the second person pasting the same link costs nothing. A burst of identical requests while the first is still running waits for it, also free. Requests are async by default; send sync: true when the preview is on the critical path.
Limits
What it does not do
- No embed HTML. It returns the page's oEmbed endpoint, not a player or rich embed for YouTube or Twitter.
- No custom fields. The shape is fixed; there is no schema to extend.
- No guessing. A value the page doesn't declare is null — the one exception is the language, which is read from the text when the page declares none.
- Public pages only. Private and intranet addresses are refused.
Pricing
What it costs
| Call | Credits | Free (calls/mo) | Starter (calls/mo) | Pro (calls/mo) | Scale (calls/mo) |
|---|---|---|---|---|---|
| /meta | 1 credit | 1,000 | 20,000 | 55,000 | 175,000 |
Cache hits and failed requests cost nothing. Paid plans are never cut off: past the allowance, extra credits are $1.50 per 1,000 credits. See every plan.
FAQ
Frequently asked questions
Which fields does the metadata API return?
Does it read Open Graph tags added by JavaScript?
What happens when og:title and the <title> disagree?
How much does it cost?
What if the page has no og:image?
Make your first request in five minutes.
Free plan, no card. Confirm your email and your API key is live — you'll be making real requests in minutes.