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.

app/api/bakery3-webhook/route.ts
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

EventWhen
render.createdThe render was accepted and priced.
render.startedA worker picked it up.
render.previewA low-sample preview is ready.
render.completedThe final image is on render.result.url.
render.failedThe render failed. render.error says why.
render.cancelledThe render was cancelled before it finished.
render.retryingAn 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.createdThe bake was accepted and priced.
bake.startedA worker picked it up.
bake.completedThe files are on bake.result.files.
bake.failedThe bake failed. bake.error says why.
bake.cancelledThe bake was cancelled before it finished.
bake.retryingThe same as render.retrying, for a bake.
batch.progressA batch's counts changed. Sent at most once every thirty seconds per batch.
batch.completedThe batch is closed and every item has finished. Sent once.
batch.pausedThe 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.