Webhooks
Get a request on your server when a render finishes, so you don't have to poll. Use it to save finished images and to keep your own job state up to date.
Each project has one webhook URL and one signing secret. Set the URL at
bakery3.com/settings. Press Rotate signing secret there to see the secret.
It is shown once.
Receive and verify an event
verifyWebhook() checks the signature and returns the event. It throws when the
request did not come from us.
import { verifyWebhook } from '@oeave/bakery3/node';
export async function POST(request: Request) {
const event = await verifyWebhook({
body: await request.text(),
signature: request.headers.get('bakery3-signature')!,
secret: process.env.BAKERY3_WEBHOOK_SECRET!,
});
if (event.event === 'render.completed' && event.render?.result) {
await storeImage(event.render.id, event.render.result.url);
}
return new Response(null, { status: 204 });
}If your framework parses the body for you, turn that off for this route. In
Express, use express.raw({ type: 'application/json' }). In the Next.js App
Router, request.text() already gives you the raw body.
Events
| Event | When |
|---|---|
render.created | The render was accepted and priced. |
render.started | A worker picked it up. |
render.preview | A low-sample preview is ready. |
render.completed | The final image is on render.result.url. |
render.failed | The render failed. render.error says why. |
render.cancelled | The render was cancelled before it finished. |
render.retrying | An attempt failed on our side and the render is queued again. render.attempts counts the attempts so far. You don't need to do anything. |
bake.created | The bake was accepted and priced. |
bake.started | A worker picked it up. |
bake.completed | The files are on bake.result.files. |
bake.failed | The bake failed. bake.error says why. |
bake.cancelled | The bake was cancelled before it finished. |
bake.retrying | The same as render.retrying, for a bake. |
batch.progress | A batch's counts changed. Sent at most once every thirty seconds per batch. |
batch.completed | The batch is closed and every item has finished. Sent once. |
batch.paused | The batch was paused, by you or because too many items were refused. batch.pausedReason says which. |
A video sends the same render.* events as an image, with the video on
render.result.url. A render that is already in the cache finishes at once,
so it sends only render.completed, with a cost of 0.
What an event contains
A render.* event carries render, a bake.* event carries bake, and a
batch.* event carries batch. Switch on event and read that one record.
{
"event": "render.completed",
"sentAt": 1735689600000,
"render": {
"id": "k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a",
"state": "completed",
"progress": 1,
"result": {
"url": "https://…",
"width": 2048,
"height": 2048,
"format": "png",
"bytes": 4912338,
"cost": 0.18,
"versions": { "translator": "5", "qualityPreset": "studio" }
},
"metadata": { "sku": "SOFA-2201" },
"createdAt": 1735689568000,
"updatedAt": 1735689600000
}
}metadata is what you passed to render({ metadata }) or bake({ metadata }).
Put your own row id in it, and the handler can find the row without a lookup
table. bake is the bake record, whose result holds
signed URLs for the bake's files instead of one image.
Events for a batch
Every item of a batch is a render, so you still get the
render.* events for each one, with render.batchId and render.batchKey set.
A handler that saves single renders already saves a batch.
The batch.* events carry the batch record:
{
"event": "batch.progress",
"sentAt": 1735689600000,
"batch": {
"id": "jd71cf4a8w2k6t9n3q5x0m7v1r8e4s2y",
"key": "fall-2026-v3",
"kind": "render",
"state": "sealed",
"lane": "batch",
"counts": { "staged": 412, "queued": 50, "running": 32, "completed": 1088,
"failed": 0, "refused": 3, "cancelled": 0, "adopted": 215 },
"total": 1800,
"done": 1306,
"cost": { "estimated": 402.5, "maximum": 543.4, "actual": 268.13 },
"createdAt": 1735689568000,
"sealedAt": 1735696800000
}
}The counts in each batch.progress are the whole state at that moment, not a
change since the last one. If you store them, overwrite what you have.
Handle the same event arriving twice
If your endpoint does not answer with a 2xx within ten seconds, we send the
event again, up to five times in total. So verify, put slow work on a queue and
answer right away. Remember what you have handled, keyed on render.id plus
event:
const seen = await db.webhookEvents.findUnique({
where: { renderId_event: { renderId: event.render.id, event: event.event } },
});
if (seen) return new Response(null, { status: 204 });Two events are sent more than once per record on purpose. render.retrying is
sent once per retry, so add render.attempts to the key. batch.progress is
sent while the counts change, so add batch.done to the key or overwrite.
Compare ids and never the whole body. A second delivery has a new signature, a
new sentAt and freshly signed URLs.
Save the image, not the URL
render.result.url is signed and stops working after 24 hours. Copy the file
into your own storage in the handler.
Read why a render failed
if (event.event === 'render.failed') {
event.render.error!.code; // 'OutOfVideoMemory'
event.render.error!.message;
event.render.error!.why;
event.render.error!.fix;
event.render.error!.retryable; // is sending the same request again worth trying?
}Troubleshooting says what each code means.
Verify without the SDK
bakery3-signature: t=1735689600000,v1=6f3a…t is a timestamp in milliseconds. v1 is the hex HMAC-SHA256 of
${t}.${body} with your signing secret as the key. Refuse a signature whose
timestamp is more than five minutes from your clock, and compare the two
digests in constant time.
Test on your own machine
Point the project's webhook URL at a tunnel to your machine and verify the same way as in production. A webhook belongs to a project, so use a separate project for development.
If verification fails, see Webhook signature does not match.
