What Is oEmbed? Discovery, Response Types and Code

oEmbed lets a site ask another site how to embed a URL and get JSON back. Discovery, the four response types, and a tested provider that returns a screenshot.

By Simon Chiu · Updated

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

1. Fetch the pagefind link rel="alternate"type="application/json+oembed" 2. Call the endpoint?url=…&maxwidth=…&format=json 3. Read the JSONversion "1.0", typephoto, video, link or rich Consumers that know the provider can skip step 1 and call a registered endpoint directly.
oEmbed discovery and request flow.

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&amp;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 rich HTML 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 a link instead.
  • & not escaped in the discovery tag. Inside HTML attributes, write &amp;; 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:image as well; the OG image entry covers it.

For preview images at scale, see link previews, or get a free API key and run the provider above with your own keys.

Frequently asked questions

What is the difference between oEmbed and Open Graph?
Open Graph tags sit in the page and describe it. oEmbed is a separate endpoint that the consumer calls, and it can return ready-made embed HTML, such as a video player, not only a title and an image.
Is oEmbed safe to use?
The photo and link types are data only. The video and rich types return HTML that the consumer inserts into its page, so only accept them from providers you trust, and put them in a sandboxed iframe.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.