> ## Documentation Index
> Fetch the complete documentation index at: https://docs.craftkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive a callback when a render completes so you can drop polling — configured in the dashboard, signed with HMAC-SHA256.

Instead of polling, subscribe a webhook so Craftkit pushes the result to your server when a render
reaches a terminal state. This is ideal for offline-replay flows: the render fires on reconnect,
and you attach the link asynchronously when the callback arrives.

## Configure a subscription

Webhooks are configured per project in the **dashboard** (Project → Webhooks): a URL, the events to
subscribe to, and an `active` flag. There is no API to register webhooks programmatically yet
(roadmap) — use the dashboard.

Delivered events:

| Event              | Fired when                                 |
| ------------------ | ------------------------------------------ |
| `render.succeeded` | a render finished and the PDF is available |
| `render.failed`    | a render terminated with an error          |

## Delivery contract

Craftkit `POST`s a JSON body to your URL with these headers:

| Header                   | Value                                                                          |
| ------------------------ | ------------------------------------------------------------------------------ |
| `x-craftkit-event`       | the event name (`render.succeeded` / `render.failed`)                          |
| `x-craftkit-signature`   | HMAC-SHA256 of the **raw body**, hex-encoded, keyed by the subscription secret |
| `x-craftkit-timestamp`   | unix seconds when the delivery was signed                                      |
| `x-craftkit-delivery-id` | unique delivery id (use for your own idempotency)                              |
| `user-agent`             | `Craftkit-Webhook/1.0`                                                         |

Body:

```json theme={null}
{
  "event": "render.succeeded",
  "renderId": "…",
  "templateId": "…",
  "status": "succeeded",
  "downloadUrl": "https://…/renders/…/….pdf",
  "errorMessage": null,
  "createdAt": "2026-06-05T10:00:00.000Z",
  "completedAt": "2026-06-05T10:00:03.120Z"
}
```

## Verify the signature

Compute the HMAC over the **raw** request body and compare against `x-craftkit-signature` (raw hex,
no `sha256=` prefix). Reject on mismatch.

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

app.post('/webhooks/craftkit', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = createHmac('sha256', process.env.CK_WEBHOOK_SECRET)
    .update(req.body) // raw Buffer
    .digest('hex');
  const got = req.header('x-craftkit-signature') ?? '';
  const ok =
    expected.length === got.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(got));
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString());
  if (event.event === 'render.succeeded') {
    storeDownloadUrl(event.renderId, event.downloadUrl);
  }
  res.json({ received: true }); // return 2xx promptly
});
```

## Retries

Failed deliveries (non-2xx, timeout, or error) retry up to **6 attempts**; after that the delivery
is marked `abandoned`. Respond `2xx` quickly (within \~15s) and do heavy work asynchronously.
De-duplicate on `x-craftkit-delivery-id` since a delivery may arrive more than once.

<Note>
  `downloadUrl` in the payload follows the same durability rules as elsewhere — a permanent public
  URL, or `null` if no public bucket is configured. See
  [Render lifecycle](/guides/render-lifecycle#downloadurl-durability).
</Note>
