This page gives you a Cloudflare Worker that serves a screenshot of any page of your site at https://your-site/og/<path>. It signs the RenderScreenshot URL inside the Worker with Web Crypto, fetches the image, and keeps it in Cloudflare's cache with the Cache API. Crawlers and browsers get the image from your own domain, and most requests never reach our API. It is part of our integrations hub.
We know this path from both sides. Our own renderer runs on Cloudflare: a rendering Worker drives the browser through Browser Rendering and writes each image to R2, and cdn.renderscreenshot.com serves the files from R2. The gateway post explains which checks run where. This page is the customer side: your Worker in front of our API.
Last tested: 2026-10-18 with wrangler 4.151.1 (
wrangler dev, local workerd) and Node 22.18. The Worker called a local stub of our API that checks signatures with the documented algorithm and the published test key pair. Results: the first/og/request rendered, the second and a?v=2variant were Cache API hits,/og//evil.example/xreturned 404, a failing page redirected to the fallback image withno-store, and redirect mode returned a URL that the stub accepted. We did not deploy it to a Cloudflare zone for this page, so it has no dashboard screenshots.
What you can build
- og:image for every page without a build step. Put
<meta property="og:image" content="https://example.com/og/blog/hello-world">in your layout. The Worker captureshttps://example.com/blog/hello-worldwith theog_cardpreset. - Thumbnails of your own pages for a site search, a docs index or an internal dashboard, served from your domain.
- A cheap front for a busy page. A popular link shared in many chats asks for the same image again and again. The edge answers those requests.
If your site runs on Cloudflare Pages and you want the middleware to write the meta tags as well, use the Pages Function version instead.
Prerequisites
- A Cloudflare zone for your site and wrangler 4.
- A RenderScreenshot signing key pair (
rs_pub_andrs_secret_). Create a free account, then follow create a signing key pair.
Step by step
- Create the project files below:
src/index.jsandwrangler.jsonc. - Store the keys as secrets:
npx wrangler secret put RS_PUBLIC_KEY_ID, thennpx wrangler secret put RS_SECRET_KEY. Forwrangler dev, put the same two names in.dev.vars. - Set
SITE_ORIGINand the route to your domain, then runnpx wrangler deploy. - Open
https://your-site/og/and check theCF-Cache-Statusheader on the second request.
// Cloudflare Worker: /og/<path> returns a screenshot of SITE_ORIGIN/<path>. // The Worker signs a RenderScreenshot URL with Web Crypto and caches the image with the Cache API. // Secrets: RS_PUBLIC_KEY_ID (rs_pub_...), RS_SECRET_KEY (rs_secret_...). Vars: SITE_ORIGIN, MODE. const API = 'https://api.renderscreenshot.com/v1/screenshot'; const EDGE_TTL = 86400; // seconds the edge keeps an image export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (request.method !== 'GET' || !url.pathname.startsWith('/og/')) { return new Response('Not found', { status: 404 }); } // Only pages of your own site, so nobody can spend your credits on other sites. const path = url.pathname.slice('/og'.length); // "/og/blog/post" -> "/blog/post" const target = new URL(path, env.SITE_ORIGIN); // "/og//evil.example" would resolve to another host. Refuse anything off your origin. if (target.origin !== new URL(env.SITE_ORIGIN).origin) return new Response('Not found', { status: 404 }); const params = { url: target.toString(), preset: 'og_card', cache_available: 'true', cache_ttl: '2592000' }; if (env.MODE === 'redirect') { // The browser or crawler fetches the image from the API itself. // One expiry per day keeps the redirect target stable, so it can be cached for an hour. const day = 86400; const expires = Math.floor(Date.now() / 1000 / day) * day + 7 * day; const signed = await signedUrl(params, env, expires); return new Response(null, { status: 302, headers: { Location: signed, 'Cache-Control': 'public, max-age=3600' } }); } // Proxy mode. The cache key ignores the query string, so ?v=1, ?v=2 ... cannot bypass the cache. const cache = caches.default; const cacheKey = new Request(url.origin + url.pathname, { method: 'GET' }); const cached = await cache.match(cacheKey); if (cached) return cached; const signed = await signedUrl(params, env, Math.floor(Date.now() / 1000) + 300); // used now, so 5 minutes const upstream = await fetch(signed); if (!upstream.ok) { const body = await upstream.json().catch(() => ({})); console.log(`render failed ${upstream.status} ${body.error?.code} ${target}`); // Do not cache failures. Send crawlers to a static fallback image instead of an error. if (env.FALLBACK_IMAGE) { return new Response(null, { status: 302, headers: { Location: env.FALLBACK_IMAGE, 'Cache-Control': 'no-store' } }); } return new Response('Screenshot unavailable', { status: 502, headers: { 'Cache-Control': 'no-store' } }); } const headers = new Headers({ 'Content-Type': upstream.headers.get('Content-Type') || 'image/png', 'Cache-Control': `public, max-age=${EDGE_TTL}`, }); const title = upstream.headers.get('X-Screenshot-Title'); if (title) headers.set('X-Screenshot-Title', title); const response = new Response(upstream.body, { status: 200, headers }); ctx.waitUntil(cache.put(cacheKey, response.clone())); return response; }, }; // Encode like the API: space -> "+", only A-Z a-z 0-9 - _ . ~ stay as they are. function encode(value) { return encodeURIComponent(String(value)) .replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()) .replace(/%20/g, '+'); } async function signedUrl(params, env, expires) { const fields = { ...params, key_id: env.RS_PUBLIC_KEY_ID, expires: String(expires) }; const query = Object.entries(fields) .map(([k, v]) => `${encode(k)}=${encode(v)}`) .sort() .join('&'); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(env.RS_SECRET_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(query)); const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join(''); return `${API}?${query}&signature=${hex}`; }
{ "name": "og-screenshots", "main": "src/index.js", "compatibility_date": "2026-10-01", "vars": { "SITE_ORIGIN": "https://example.com", "MODE": "proxy", "FALLBACK_IMAGE": "https://example.com/og-default.png" }, "routes": [{ "pattern": "example.com/og/*", "zone_name": "example.com" }] }
How the Worker decides
flowchart TD
R[GET /og/path] --> O{Target on SITE_ORIGIN?}
O -- no --> N[404]
O -- yes --> M{MODE}
M -- redirect --> S[302 to signed API URL]
M -- proxy --> C{Cache API hit?}
C -- yes --> H[Return cached image]
C -- no --> F[Sign, fetch API]
F -- 200 --> P[Return image, cache.put in waitUntil]
F -- error --> B[302 to FALLBACK_IMAGE, no-store]
Three choices matter more than the rest:
- Only your origin. Without the origin check,
/og//evil.example/xresolves to another host, and anyone could spend your credits on any site. The Worker compares the origin after it parses the URL. - The cache key drops the query string. Otherwise
?v=1,?v=2and so on would each miss the cache and each cost an API request. - Failures are never cached. A
timeouttoday should not become a broken card for a day. The fallback redirect carriesno-store.
Proxy mode or redirect mode
MODE: "proxy" (default) |
MODE: "redirect" |
|
|---|---|---|
| Who calls the API | The Worker | The browser or crawler, after a 302 |
| Image URL that crawlers see | Your domain | Your domain, then api.renderscreenshot.com |
| Edge cache | Cache API, per data center | None in the Worker. Clients may keep the 302 for one hour |
| Requests to the API | At most one per page, per data center, per EDGE_TTL |
One per fetch that reaches the API |
| Signed URL expiry | 5 minutes (used at once) | 7 days, rounded to the day so the URL stays stable |
Use proxy mode on a custom domain or route. Cloudflare's Cache API docs say that cache operations work there, and have no effect in the dashboard editor and the Playground. Redirect mode is for places where you cannot cache, such as previews. Its requests count against your account's per-minute limit, but with cache_available=true repeat renders use 0 credits.
Cloudflare's docs (checked October 2026) also describe Workers Caching, a newer cache that sits in front of the Worker, is tiered and collapses concurrent requests. The Cache API used here is local to each data center. For high traffic, read both pages before you choose.
Parameter mapping
| Worker setting | API parameter | Value |
|---|---|---|
SITE_ORIGIN + path after /og |
url |
https://example.com/blog/hello-world |
| (code) | preset |
og_card, 1200 × 630 PNG. See presets. |
| (code) | cache_available, cache_ttl |
true, 2592000: reuse a render for up to 30 days |
RS_PUBLIC_KEY_ID |
key_id |
rs_pub_... |
| (code) | expires |
Unix seconds, at most 30 days ahead |
RS_SECRET_KEY |
signs signature |
Never sent |
EDGE_TTL |
none | How long your zone keeps the image |
To add options, put more flat GET parameters in params, for example block_cookies: 'true'. Add them before signing. A parameter added after signing fails with invalid_signature.
Errors and retries
The Worker does not retry inside the request, because a crawler waits for the answer. Failures are not cached, so the next request for the same card tries again. In wrangler tail, each failure logs one line with the status, the error.code and the target.
| Logged code | Meaning | Fix |
|---|---|---|
invalid_signature |
The string to sign differs from the server's | Do not change encode(). Spaces must become +. |
invalid_expiration |
expires is in milliseconds or more than 30 days ahead |
Use Unix seconds |
missing_credentials |
Unknown or revoked rs_pub_ key |
Check the secret, or create a new pair |
too_many_requests |
The account used its per-minute limit | Lower traffic to the API: keep proxy mode and a long EDGE_TTL |
timeout |
The page did not load in 30 seconds | Fix the page, or add timeout: '45' to params |
Limits and costs
- Credits. A miss in a new data center calls the API. With
cache_available, only the first render of a page in 30 days uses a credit. The free plan gives 150 credits each month. See pricing. - Rate limit. Each call to the API counts toward your account's per-minute limit, 10 on the free plan. Cache hits in your zone do not reach us, so they do not count. See rate limits.
- Workers. Time spent waiting on
fetch()is not CPU time. The work in the Worker is one HMAC and a cache lookup.
Troubleshooting
CF-Cache-Statusnever showsHIT. You are on a preview or dashboard editor, or the route is not on your zone. Test on the real route.- Every request returns the fallback. Run
npx wrangler tailand read the logged code. - The card shows an old version of the page. Both caches hold it: your zone for
EDGE_TTL, and our API forcache_ttl. Purge the URL in your zone, and send one request withcache_refresh: 'true'signed in, or purge the render.