Use this checklist before you ship pages that will be shared on social platforms and chat apps. Each item says what to check and how to check it. Then a short script runs the same checks against a live page, and a Cloudflare Worker serves cards from your own domain with storage that does not expire. The guide is part of the OG image API docs. For size limits per platform and a full card template, read Generate OG images from HTML.

Tested on 2026-10-18 with Node.js 22.18: the checker script ran against example.com and against a local test page; the Worker ran under Node with a stub R2 bucket and a stub of the screenshot API (render, failure, missing card, old version and HEAD cases). Neither was run on Cloudflare or against the live API.

The checklist

Tags

  1. og:image holds an absolute https:// URL. Relative paths fail on most platforms.
  2. og:image:width and og:image:height match the real file (1200 and 630 with the og_card preset). They let a platform lay out the card before it downloads the image.
  3. og:image:alt and twitter:image:alt describe the card in plain text, for example "Release notes for version 2.4, October 2026". Do not repeat the page title word for word.
  4. twitter:card is summary_large_image, written with name=. Open Graph tags use property=.
  5. Every & inside a content attribute is written as &, once. Double escaping (&) breaks signed URLs.

The image URL

  1. The URL answers 200 with an image/* Content-Type to an anonymous request. No cookies, no login, no robots.txt rule that blocks crawlers from the image path.
  2. No redirect. The tag holds the final URL.
  3. The first fetch already gets the final image. A placeholder that you replace later stays in the platform cache.
  4. The URL changes when the card changes. Platforms cache by URL, so put a version or the update date in it. Purging our cache does not change copies that platforms already hold.
  5. The URL outlives every share. A signed URL lives at most 30 days and an X-Cache-URL link at most its cache TTL. Pages that are shared for months need a stored file.

The card itself

  1. Text is large enough to read at chat-app size. Check the card at about 400 px wide.
  2. Fonts and images on the card template are loaded before capture. The wait options control this; a card route with no client-side data needs none.
  3. The card template does not run ad blockers or cookie banners against itself. Leave block_ads and block_cookies off for your own template.

Platform table: who fetches, and how to refresh

The column that matters most when a card looks wrong is the last one: how to make the platform fetch again. Checked on 2026-10-18 against each platform's own pages where one exists.

Platform Crawler user agent Tags read How to get a new preview
Facebook facebookexternalhit/1.1 (crawler docs) Open Graph; tags must be in the first 1 MB of the page Sharing Debugger, "Scrape Again"
LinkedIn Not named in its help page Open Graph; image at least 1200×627, at most 5 MB (LinkedIn help) Post Inspector
Slack Slackbot-LinkExpanding 1.0 (Slack robots page) oEmbed, Twitter Card, Open Graph Slack caches unfurls for about 30 minutes; old messages keep their preview
X X's developer docs were not reachable on the check date twitter:*, falls back to og:* Post the link again after changing the image URL
Discord, iMessage, WhatsApp, Telegram No public crawler documentation found Open Graph Use a new image URL; existing messages keep their preview

Check a live page

This script fetches the page with Facebook's crawler user agent, reads the tags, then fetches the image without following redirects. It prints each failed check and exits with code 1, so it can run in CI after a deploy.

// check-card.mjs: node check-card.mjs https://www.example.com/blog/hello-world
const pageUrl = process.argv[2];
const UA = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
const problems = [];

const page = await fetch(pageUrl, { headers: { 'User-Agent': UA } });
const html = await page.text();

// Collect <meta property|name=... content=...> pairs (good enough for a check, not a full parser).
const meta = {};
for (const tag of html.match(/<meta\s[^>]*>/gi) ?? []) {
  const key = tag.match(/(?:property|name)\s*=\s*["']([^"']+)["']/i)?.[1];
  const value = tag.match(/content\s*=\s*"([^"]*)"|content\s*=\s*'([^']*)'/i);
  if (key && value) meta[key.toLowerCase()] ??= (value[1] ?? value[2]).replaceAll('&amp;', '&');
}

for (const k of ['og:title', 'og:image', 'og:image:width', 'og:image:height', 'og:image:alt', 'twitter:card', 'twitter:image:alt']) {
  if (!meta[k]) problems.push(`missing ${k}`);
}
if (meta['twitter:card'] && meta['twitter:card'] !== 'summary_large_image') problems.push(`twitter:card is ${meta['twitter:card']}`);

const image = meta['og:image'];
if (image && !image.startsWith('https://')) problems.push('og:image is not an absolute https URL');
if (image?.startsWith('https://')) {
  const res = await fetch(image, { headers: { 'User-Agent': UA }, redirect: 'manual' });
  const type = res.headers.get('content-type') ?? '';
  const bytes = (await res.arrayBuffer()).byteLength;
  if (res.status >= 300 && res.status < 400) problems.push(`og:image redirects (${res.status}) to ${res.headers.get('location')}`);
  else if (res.status !== 200) problems.push(`og:image returned HTTP ${res.status}`);
  if (res.status === 200 && !type.startsWith('image/')) problems.push(`og:image Content-Type is ${type}`);
  if (bytes > 5 * 1024 * 1024) problems.push(`og:image is ${bytes} bytes; LinkedIn's limit is 5 MB`);
  console.log(`image: HTTP ${res.status}, ${type}, ${bytes} bytes`);
}

console.log(problems.length ? problems.map((p) => `FAIL ${p}`).join('\n') : 'PASS all checks');
process.exitCode = problems.length ? 1 : 0;

Run against https://example.com, which has no social tags, it prints seven FAIL missing ... lines and exits with 1. If og:image is a signed RenderScreenshot URL, each run is a real fetch: it costs a credit unless the URL was signed with cache_available=true and the render is still cached.

Two ways to get the image URL

GET: a signed URL in the tag. Fastest to ship, with no infrastructure. The API renders on the first crawler fetch. The URL must expire within 30 days. See signed URLs in og:image tags for code in six frameworks.

POST: render once, store the file on your domain. Use this when shares must keep working for months, or when you want the image URL on your own domain. The Worker below holds your rs_live_ key as a secret, renders each card once with POST /v1/screenshot, and keeps the PNG in an R2 bucket. It is not the same as the edge signing Worker, which signs URLs and uses the Cache API; this one stores files that do not expire.

// worker.js: serves https://www.example.com/og/<slug>-v<version>.png from R2.
// The first request renders the card with the screenshot API and stores it; later requests read R2.
const API = 'https://api.renderscreenshot.com/v1/screenshot';
const PATH = /^\/og\/([a-z0-9-]{1,80})-v(\d{1,10})\.png$/;

function imageResponse(body, method) {
  return new Response(method === 'HEAD' ? null : body, {
    headers: {
      'Content-Type': 'image/png',
      // The URL changes when the card changes, so the file never needs revalidation.
      'Cache-Control': 'public, max-age=31536000, immutable',
    },
  });
}

export default {
  async fetch(request, env) {
    const match = new URL(request.url).pathname.match(PATH);
    if (!match || !['GET', 'HEAD'].includes(request.method)) {
      return new Response('Not found', { status: 404 });
    }
    const [, slug, version] = match;
    const key = `og/${slug}-v${version}.png`;

    const stored = await env.OG_BUCKET.get(key);
    if (stored) return imageResponse(stored.body, request.method);

    // Only render cards that exist. The template route answers 404 for an unknown slug
    // or an old version, so nobody can make you pay for renders of made-up URLs.
    const cardUrl = `${env.CARD_ORIGIN}/og-template/${slug}?v=${version}`;
    const probe = await fetch(cardUrl, { method: 'HEAD' });
    if (!probe.ok) return new Response('Not found', { status: 404 });

    const render = await fetch(API, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${env.RS_API_KEY}`, // a Worker secret, never in HTML
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ url: cardUrl, preset: 'og_card' }),
    });
    if (!render.ok || !(render.headers.get('Content-Type') || '').startsWith('image/')) {
      // Do not store or cache a failure: the next crawler fetch tries again.
      return new Response('Card render failed', { status: 502, headers: { 'Cache-Control': 'no-store' } });
    }

    const bytes = await render.arrayBuffer();
    await env.OG_BUCKET.put(key, bytes, { httpMetadata: { contentType: 'image/png' } });
    return imageResponse(bytes, request.method);
  },
};

Bind an R2 bucket as OG_BUCKET, set CARD_ORIGIN as a variable and RS_API_KEY with wrangler secret put, then route www.example.com/og/* to the Worker. Your page puts https://www.example.com/og/hello-world-v3.png in og:image, where 3 is the post's version. Three design choices matter:

  • The version is in the file name. Editing a post increments it, so platforms see a new URL and a new file is rendered. The template route rejects old versions, which stops anyone from creating unlimited files by guessing numbers.
  • Failures are not stored. A failed render returns 502 with no-store, so a later fetch tries again.
  • Two first fetches at the same moment both render. That costs one extra credit, once. To avoid it, warm the URL from your deploy script before you publish the post.

The storage option can do the bucket write for you on paid plans, with your own S3-compatible bucket and a path template that contains {hash} and {ext}. The Worker keeps a stable, readable file name that you control.

Troubleshooting

Symptom Likely cause Fix
Debugger shows no image The image URL redirects, needs a cookie, or is blocked in robots.txt Run the checker; serve a direct 200 image/png
Old card after an edit Same image URL as before Change the version in the URL, then re-scrape
Card shows a cookie banner or a login page url points to a normal page, not a card template Point it at a template route that needs no session
Blank or half-loaded card Fonts or images on the template were still loading Inline fonts, or wait for them with the wait options
Card worked for weeks, then disappeared on re-shares The signed or X-Cache-URL link expired Store the file, as in the Worker
Worker returns 502 The render failed Call the API with the same body from curl and read the error; codes are listed in error codes

Limits to know

  • Signed URLs: expires is required, at most 30 days ahead.
  • Cache TTL: 1 hour to 30 days. X-Cache-URL links are not permanent.
  • The rate limit is per account and shared by all keys, including the Worker's. See rate limits.

Get a free API key to run the Worker with your own card template.

Related in this topic

Was this page helpful?