oEmbed is an open format that lets a website (the consumer) ask another website (the provider) how to embed one of its URLs: the consumer calls the provider's oEmbed endpoint with the URL, and the provider answers with JSON that contains a title, sizes and either an image or ready-to-insert HTML.
YouTube, Vimeo, Flickr, Spotify, SoundCloud and many CMSs are providers. WordPress, Slack and Discourse are consumers. This entry is part of the screenshot and rendering glossary.
A plain explanation
When you paste a video URL into a blog editor and a player appears, oEmbed is usually behind it. The editor does not scrape the video page. It asks the video site: "how should I show this URL?" The site answers: "here is a 560 x 315 iframe player, the title, the author and a thumbnail." The editor inserts the answer.
Compared with link unfurling from Open Graph tags, the provider stays in control of what the embed looks like, and it can return something interactive.
Discovery and the request
A provider advertises its endpoint with a tag in the page head:
<link rel="alternate" type="application/json+oembed" href="https://example.com/oembed?url=https%3A%2F%2Fexample.com%2Fposts%2F42&format=json" title="Post 42">
The consumer sends url and may send maxwidth, maxheight and format (json or xml). The response always has version: "1.0" and a type:
type |
Required fields | What the consumer shows |
|---|---|---|
photo |
url, width, height |
A static image |
video |
html, width, height |
An embedded player, as HTML |
rich |
html, width, height |
Any other embed, as HTML |
link |
none beyond version and type |
A plain link, maybe with a thumbnail |
Optional fields include title, author_name, provider_name, cache_age and thumbnail_url with thumbnail_width and thumbnail_height. The provider must keep a photo, video or rich embed within the consumer's maxwidth and maxheight, and should return HTTP 501 for a format it does not support.
A provider that returns a screenshot
Many sites have pages worth previewing but no image to offer. A small oEmbed endpoint can answer with a screenshot of the page itself. We built one that returns a photo (the 1200 x 630 og_card render) when the consumer allows that size, and a link with the same image as a thumbnail when it does not.
// A minimal oEmbed provider that answers with a screenshot of the page. // GET /oembed?url=https://your-site.com/posts/42&maxwidth=600 import http from 'node:http'; import { signedScreenshotUrl } from './sign.mjs'; // the function from the signed URL docs const SITE = 'https://example.com/'; // only describe your own pages http.createServer((req, res) => { const q = new URL(req.url, 'http://localhost').searchParams; const target = q.get('url') ?? ''; if (!target.startsWith(SITE)) { res.writeHead(404).end(); return; } if ((q.get('format') ?? 'json') !== 'json') { res.writeHead(501).end(); // spec: return 501 for formats you do not support return; } const image = signedScreenshotUrl( { url: target, preset: 'og_card' }, // 1200 x 630 { keyId: process.env.RS_PUBLIC_KEY_ID, secret: process.env.RS_SECRET_KEY, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), }, ); const base = { version: '1.0', title: 'Page preview', provider_name: 'Example', cache_age: 86400 }; // A photo must fit maxwidth/maxheight. The card is 1200 x 630, so when it // does not fit, answer with a link and offer the card as a thumbnail. const fits = (Number(q.get('maxwidth')) || Infinity) >= 1200 && (Number(q.get('maxheight')) || Infinity) >= 630; const body = fits ? { ...base, type: 'photo', url: image, width: 1200, height: 630 } : { ...base, type: 'link', thumbnail_url: image, thumbnail_width: 1200, thumbnail_height: 630 }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); }).listen(8080, () => console.log('oEmbed provider on http://localhost:8080/oembed'));
signedScreenshotUrl is the Node function from the signed URL docs, saved as sign.mjs. The secret key stays on the server; the URL carries only the public key ID, the expiry and the signature. Signed URLs can be valid for at most 30 days, so a cache_age of one day keeps consumers well inside that.
Results with test keys: without maxwidth, the endpoint returned "type":"photo" with width 1200 and height 630. With maxwidth=600, it returned "type":"link" with the thumbnail fields. A URL outside the site returned 404, and format=xml returned 501.
How to read it in Puppeteer and Playwright
Discovery tags are usually in the server HTML, but some sites add them with JavaScript. A headless browser sees both.
Puppeteer.
import puppeteer from 'puppeteer'; const url = process.argv[2] ?? 'https://example.com'; const browser = await puppeteer.launch(); try { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'load' }); // The discovery tag as the rendered DOM has it (after JavaScript ran). const href = await page.$eval('link[type="application/json+oembed"]', (l) => l.href).catch(() => null); if (!href) { console.log('no oEmbed discovery link'); } else { const res = await fetch(href, { signal: AbortSignal.timeout(5000) }); if (!res.ok) throw new Error(`oEmbed endpoint returned ${res.status}`); const data = await res.json(); console.log(data.type, data.title ?? '', data.url ?? data.thumbnail_url ?? ''); } } finally { await browser.close(); }
Playwright.
import { chromium } from 'playwright'; const url = process.argv[2] ?? 'https://example.com'; const browser = await chromium.launch(); try { const page = await browser.newPage(); await page.goto(url); const link = page.locator('link[type="application/json+oembed"]'); if ((await link.count()) === 0) { console.log('no oEmbed discovery link'); } else { const href = new URL(await link.first().getAttribute('href'), page.url()).href; const res = await page.request.get(href); console.log(res.status(), await res.json()); } } finally { await browser.close(); }
Tested on 2026-10-15 with Node 22.18.0, Puppeteer 25.14.0 (Chrome 155) and Playwright 1.64.1 (Chromium 156). On https://example.com both scripts printed "no oEmbed discovery link"; on a local page with a discovery tag both returned the photo response. The provider ran locally with test keys; the signed screenshot URLs it built were not fetched.
Common mistakes
- Inserting
richHTML from anyone. It is third-party markup in your page. Allow-list providers and sandbox the HTML. - Ignoring
maxwidth. Consumers size their layout from it. Return a smaller embed or alinkinstead. ¬ escaped in the discovery tag. Inside HTML attributes, write&; a parser that does not decode it calls the wrong URL.- Expecting every platform to use oEmbed. Many social networks read only Open Graph tags. Keep an
og:imageas well; the OG image entry covers it.
Related terms
- Link unfurling: the wider process, of which oEmbed is one source.
- Open Graph protocol: tags in the page instead of an endpoint.
- Signed URL: how the provider above links to images without exposing a key.
For preview images at scale, see link previews, or get a free API key and run the provider above with your own keys.