Webhooks
Get a signed HTTP request when a generation finishes.
Instead of polling, register an HTTPS endpoint and we'll POST an event to it whenever one of your
generations succeeds, fails or is canceled.
Register an endpoint
On the dashboard's Webhooks page, or through the API with a key that has webhooks:write:
import { RawFrame } from "@rawframe/sdk";
const rf = new RawFrame(); // needs the webhooks:write scope
const endpoint = await rf.webhookEndpoints.create({
url: "https://example.com/webhooks/rawframe",
event_types: ["generation.succeeded", "generation.failed"],
});
// Store the secret now: it is only returned once. Set it as RAWFRAME_WEBHOOK_SECRET for your receiver.
console.log(endpoint.id, endpoint.secret.slice(0, 10));The response includes the signing secret (whsec_...), only this once. You can rotate it on the
dashboard. An organization can have up to 20 endpoints, each subscribed to some or all event types.
Events
| Type | Sent when |
|---|---|
generation.succeeded | A generation finished with outputs |
generation.failed | A generation failed (error says why) |
generation.canceled | A generation was canceled |
{
"id": "evt_01jbxb2f...",
"type": "generation.succeeded",
"created_at": "2026-10-30T12:00:00.000Z",
"data": { "generation": { "object": "generation", "id": "gen_01jbxa3m...", "status": "succeeded", "...": "..." } }
}data.generation is the same object GET /v1/generations/{id} returns. Output URLs in it expire an hour
after the event was created. If you process events later than that, fetch the generation for fresh ones.
Verify every request
Your endpoint is a public URL, so check that each request really came from us. Events are signed per the Standard Webhooks spec. Each request has three headers:
webhook-id: the event id. The same on every retry of one event.webhook-timestamp: Unix seconds when this attempt was signed.webhook-signature:v1,followed by the base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret afterwhsec_. There may be several space-separated signatures.
The SDKs check the signature and the timestamp (within 5 minutes) for you. Pass the raw body, exactly as received: re-serialized JSON won't match.
import { createServer } from "node:http";
import { verifyWebhook, WebhookVerificationError } from "@rawframe/sdk";
// The whsec_... secret returned when you created the endpoint.
const secret = process.env.RAWFRAME_WEBHOOK_SECRET ?? "";
createServer(async (req, res) => {
// Verify the raw body exactly as received, before parsing it.
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const body = Buffer.concat(chunks).toString("utf8");
try {
const event = await verifyWebhook(body, req.headers, secret);
// Deliveries can repeat: use event.id to process each event once.
const generation = event.data.generation;
if (event.type === "generation.succeeded") {
console.log(`${generation.id} succeeded: ${generation.outputs?.[0]?.url}`);
} else {
console.log(`${generation.id} ${generation.status}: ${generation.error?.code ?? "canceled"}`);
}
res.writeHead(204).end(); // Any 2xx acknowledges the event.
} catch (err) {
if (!(err instanceof WebhookVerificationError)) throw err;
res.writeHead(400).end();
}
}).listen(Number(process.env.PORT ?? 3000));With a web framework, read the raw body before any JSON parsing: express.raw({ type: "application/json" })
in Express, await request.text() in Next.js route handlers, request.get_data() in Flask, or
await request.body() in FastAPI. Standard Webhooks libraries for other languages also work with our
secrets.
Responding and retries
Return any 2xx within 15 seconds to acknowledge an event. Do slow work after responding, for example
by queueing it. Anything else (a non-2xx status, a timeout, a connection error) is retried with the
same webhook-id and body after 30 seconds, 2 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12
hours: 8 attempts over about 22 hours. Redirects aren't followed.
Because of retries, an event can arrive more than once, and events can arrive out of order. Use the
event id to process each one once, and the generation's status as the source of truth.
If an endpoint answers 410 Gone, or has failed continuously for 3 days, we disable it and email your
organization's owners and admins. Re-enable it on the dashboard or with
PATCH /v1/webhook_endpoints/{id} and {"status": "enabled"}. The Webhooks page shows each
delivery's attempts and responses, and can resend one.
Endpoints must be public HTTPS URLs. Private and internal addresses are refused.