Generate Dynamic Open Graph Images From Webhooks
Build webhook-driven Open Graph images with a secure Next.js renderer, cache-safe URLs, validation, and a hosted ScreenshotNeo option.
Direct answer: receive and authenticate the webhook, validate and normalize the fields you need, render a deterministic 1200×630 image from a template, and expose that image at a public absolute URL. Set the URL as og:image. A reliable implementation separates the webhook receiver from the image route, versions image URLs when data changes, and caches identical renders.
1. Architecture: webhook to social card
The webhook is the trigger; the image endpoint is the rendering boundary. Your flow should look like this:
- Your application receives a signed event.
- The receiver verifies the signature and validates the schema.
- It selects a small allowlist of fields such as title, author, status, price, or release date.
- It stores or encodes those values into a deterministic image URL.
- A public image route renders the template and returns PNG bytes.
- Your page sets that absolute URL in
og:image.
Use an absolute HTTPS URL that social crawlers can fetch without cookies or authentication. Vercel documents 1200×630 pixels as the recommended Open Graph size and states that @vercel/og uses Satori and Resvg to convert HTML and CSS to PNG (Next.js ImageResponse documentation).
2. Create the image route with Next.js ImageResponse
In a Next.js App Router project, create app/api/og/route.tsx. This route accepts URL parameters, escapes untrusted values by rendering them as text, and returns a 1200×630 PNG.
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge';
function clamp(value: string | null, max: number): string {
return (value ?? '').trim().slice(0, max);
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = clamp(searchParams.get('title'), 120) || 'New release';
const author = clamp(searchParams.get('author'), 60);
const status = clamp(searchParams.get('status'), 30);
const accent = /^#[0-9a-fA-F]{6}$/.test(searchParams.get('accent') ?? '')
? searchParams.get('accent')!
: '#6d5dfc';
return new ImageResponse(
(
{status || 'UPDATE'}
{title}
{author ? (
By {author}
) : null}
example.com
),
{ width: 1200, height: 630 }
);
}
ImageResponse supports flexbox and a documented CSS subset. CSS Grid and other unsupported layout features will not render as they would in a browser. Supported font formats include TTF, OTF, and WOFF; the documentation prefers TTF or OTF for parsing speed. Keep the complete bundle, including fonts, JSX, CSS, images, and other assets, under the documented 500KB maximum.
Loading a custom font
import { ImageResponse } from 'next/og';
import fontData from './Inter-Bold.ttf';
export const runtime = 'edge';
export async function GET() {
return new ImageResponse(
<div style={{ display: 'flex', fontSize: 64 }}>Hello</div>,
{
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: fontData, weight: 700, style: 'normal' }],
}
);
}
3. Receive, verify, and normalize the webhook
Do not trust payload fields or a URL supplied by the sender. Verify the provider’s signature using its official SDK or signing algorithm, reject stale timestamps, enforce a body-size limit, and validate the JSON schema. The following example shows the structure using an HMAC signature; adapt the header and canonical signing format to your provider.
// app/api/webhooks/content/route.ts
import crypto from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
const secret = process.env.WEBHOOK_SECRET!;
function validSignature(rawBody: string, signature: string | null) {
if (!signature) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const rawBody = await request.text();
if (Buffer.byteLength(rawBody, 'utf8') > 256 * 1024) {
return NextResponse.json({ error: 'payload too large' }, { status: 413 });
}
if (!validSignature(rawBody, request.headers.get('x-webhook-signature'))) {
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
}
let input: unknown;
try { input = JSON.parse(rawBody); }
catch { return NextResponse.json({ error: 'invalid JSON' }, { status: 400 }); }
const event = input as { id?: string; type?: string; data?: Record };
if (!event.id || !event.type || !event.data) {
return NextResponse.json({ error: 'invalid schema' }, { status: 422 });
}
// Make this operation idempotent: ignore event.id values already processed.
const title = String(event.data.title ?? '').trim().slice(0, 120);
const author = String(event.data.author ?? '').trim().slice(0, 60);
const status = String(event.data.status ?? '').trim().slice(0, 30);
const version = String(event.data.updatedAt ?? event.id);
// Persist { event.id, title, author, status, version } in your database.
// Your page can then use /api/og?contentId=...&v=version.
console.log({ eventId: event.id, title, author, status, version });
return NextResponse.json({ accepted: true });
}
Use an idempotency key such as the provider’s event ID. Webhook systems retry, and duplicate processing should not create inconsistent cards. Return a fast 2xx response after durable storage; render the image lazily when a crawler requests it.
4. Connect the page metadata
import type { Metadata } from 'next';
export function generateMetadata({ params }: { params: { slug: string } }): Metadata {
const version = '2025-02-14T10:30:00Z';
const image = new URL('https://www.example.com/api/og');
image.searchParams.set('title', 'Acme launches webhooks');
image.searchParams.set('author', 'Acme engineering');
image.searchParams.set('status', 'NEW RELEASE');
image.searchParams.set('v', version);
return {
title: 'Acme launches webhooks',
openGraph: {
title: 'Acme launches webhooks',
images: [{ url: image.toString(), width: 1200, height: 630, type: 'image/png' }],
},
};
}
The URL must be publicly reachable by LinkedIn, Slack, Facebook, and X crawlers. Allow the route in robots.txt; Vercel’s example permits /api/og/*. Do not require a session cookie, private network access, or an API key on the crawler-facing route.
5. Make updates cache-safe
Social platforms cache images independently, so changing the response behind the same URL may leave an old card visible. Put a content version, publication timestamp, or content hash in the URL:
https://www.example.com/api/og?contentId=post_123&v=7
For deterministic URLs, send long-lived cache headers and let your CDN serve repeat requests. OGKit documents a 24-hour CDN cache for repeated parameter combinations; purging a provider cache does not necessarily purge a social network’s own cache (OGKit caching documentation). Version URLs when the title, author, status, or artwork changes.
6. Template and input rules
| Concern | Recommendation |
|---|---|
| Text length | Clamp title and author lengths; design for wrapping and provide fallbacks. |
| Characters | Bundle a font that contains the scripts you publish, or missing glyphs may appear. |
| Remote images | Allowlist hosts, fetch server-side with timeouts, and reject unexpected content types and huge files. |
| Colors | Validate user-supplied colors against a strict format such as six-digit hexadecimal. |
| Layout | Use flexbox and supported CSS; avoid Grid, animations, browser APIs, and client-side JavaScript. |
| Payloads | Store the small normalized record and pass an ID rather than embedding the full webhook body in a URL. |
7. Testing checklist
- Send a real signed webhook and confirm invalid signatures return 401.
- Replay the same event and confirm idempotency prevents duplicate writes.
- Open the image URL without cookies in an incognito window and with
curl -I. - Check
Content-Type: image/png, 1200×630 dimensions, and a 200 response. - Test long titles, missing optional fields, Unicode, right-to-left text, and unsupported characters.
- Fetch the page HTML and verify
og:imageis absolute and versioned. - Use each target network’s debugger or link preview tool after publishing a new version.
8. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 from webhook route | Wrong secret, header, timestamp, or raw-body handling. | Verify the provider’s exact signing recipe and calculate the signature before parsing JSON. |
| Image route returns 500 | Unsupported CSS, malformed JSX, missing font, or an oversized asset. | Reduce the template to flexbox, inspect deployment logs, bundle a supported font, and stay below 500KB. |
| Text is clipped | Fixed dimensions do not account for long or translated text. | Clamp input, lower font size for long values, and test worst-case strings. |
| Social preview is old | Crawler or CDN cache. | Change the version query parameter and request the new absolute URL. |
| Preview is blank | Route is private, blocked by robots, or requires a cookie. | Allow the path, deploy it publicly over HTTPS, and remove authentication from the image request. |
| Remote logo fails | Unsupported image format, blocked host, timeout, or excessive size. | Use an allowlist, fetch with a timeout, validate content type, and prefer a bundled asset. |
| Duplicate cards for one event | Webhook retry without idempotency. | Persist the event ID with a unique constraint before rendering. |
9. Performance, reliability, and cost
Keep webhook handling short and move rendering to the image request. Deterministic URLs allow edge and CDN caching, reducing repeated rendering. Avoid fetching several remote assets during every render; bundle small assets or cache them. Measure cold-start latency, image generation time, response size, and cache-hit rate in your own deployment because the cited documentation does not provide independent performance benchmarks.
For reliability, return a fallback card when optional data is missing, use bounded timeouts for remote resources, and log a correlation ID from the webhook through the image request. Store the normalized event so a later crawler request does not depend on the original webhook sender being available.
Your cost is the sum of webhook infrastructure, image execution, storage, CDN transfer, and any hosted renderer fees. Cache repeated versions and generate only on demand unless you need prewarming for a large launch.
10. Or skip the browser setup
If you need a clean screenshot of a rendered page or a social card assembled by an existing URL, ScreenshotNeo provides a single GET request. It accepts the cookie or consent banner before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and each response reports its verdict in X-Page-Verdict and X-Billed headers. An MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for all options. A minimal call is:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
const buffer = Buffer.from(await res.arrayBuffer());
await Bun.write('shot.webp', buffer);
Every feature is included on every plan. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.
11. FAQ
Can a webhook itself be the og:image URL?
No. A webhook receives events; crawlers need a stable, public image URL that returns image bytes.
Should I generate the PNG when the webhook arrives?
Usually render on demand and cache by version. Pre-render when you need guaranteed availability before a coordinated announcement.
Why use 1200×630?
It is Vercel’s documented recommended Open Graph size and is widely accepted by social crawlers.
Can I use CSS Grid in ImageResponse?
No. The documented renderer supports flexbox and a CSS subset, so design within those constraints.
How do I force a new preview?
Publish a new versioned image URL. Social networks maintain their own caches.


