# Webhooks

> Get an HTTPS request when a run succeeds, fails or is about to retry, and verify that it came from Runnel with the Runnel-Signature header.

Source: https://galley.themes.dev/docs/reference/webhooks/

Webhooks tell your systems about runs without polling. Add an endpoint in the dashboard under **Settings › Webhooks**, choose its events and environment, and Runnel sends a `POST` with a JSON body each time one of them happens. Each endpoint gets its own signing secret, starting with `rwhsec_`.

## Events

| Event | Sent when |
|---|---|
| `run.succeeded` | A run finished successfully. |
| `run.failed` | A run failed and has no attempts left, or threw `runnel.fatal()`. |
| `run.retrying` | An attempt failed and another one is queued. Sent once per failed attempt. |

Every event has the same envelope. `data.run` is the run object from the [Runs API](/docs/reference/runs-api/), without its steps.

```json
{
  "id": "evt_01JB7Y3N0R5KD2",
  "type": "run.failed",
  "created_at": "2026-10-28T09:31:41Z",
  "data": {
    "run": {
      "id": "run_01JB7X2K9Q4MZ8",
      "job": "send-invoice",
      "status": "failed",
      "attempt": 5,
      "max_attempts": 5,
      "error": { "message": "PDF renderer timed out after 2m" },
      "environment": "production"
    }
  }
}
```

## Verifying signatures

Every request carries a `Runnel-Signature` header such as `t=1761650400,v1=5f2c…`. `t` is the Unix time the request was signed. `v1` is the hex HMAC-SHA256 of the string `<t>.<raw body>`, keyed with the endpoint's secret. Check both before you trust the body.

The SDKs do this for you. Pass the raw body, not parsed JSON, because re-serialising changes the bytes:

```ts title="src/routes/runnel-webhook.ts"
import { runnel } from '@runnel/sdk';

export async function POST(req: Request) {
  const body = await req.text();
  let event;
  try {
    event = runnel.webhooks.verify(body, req.headers.get('runnel-signature'), process.env.RUNNEL_WEBHOOK_SECRET!);
  } catch {
    return new Response('Bad signature', { status: 400 });
  }
  if (event.type === 'run.failed') {
    await pageOnCall(`${event.data.run.job} failed: ${event.data.run.error?.message}`);
  }
  return new Response(null, { status: 204 });
}
```

In Python, use `runnel.webhooks.verify(body, header, secret)`; in Go, `runnel.VerifyWebhook(body, header, secret)`. Both raise or return an error on a bad or old signature.

> **Warning** Reject a request when `t` is more than five minutes from your clock, even if the signature matches. Otherwise a captured request can be replayed later.

If you verify by hand, compare signatures in constant time (`crypto.timingSafeEqual`, `hmac.compare_digest`). While you rotate a secret, the header carries two `v1` values, one per secret, for 24 hours; accept the request if either matches.

## Delivery and retries

Respond with any 2xx status within 10 seconds. Anything else, including a timeout, counts as a failure and Runnel tries again with backoff: after 30 seconds, 2 minutes, 10 minutes, then hourly, for 24 hours in all. After that the delivery is marked failed and shown in the dashboard, where you can resend it.

- Deliveries can arrive more than once and out of order. Use the event `id` to skip duplicates, and the run's `status` rather than the event order to decide what is current.
- Do slow work after you respond. Enqueue a job from the handler instead of doing the work inside it.
- An endpoint that fails every delivery for three days is paused, and the project owners get an email.

> **Note** The `X-Runnel-Signature` header, which signed the body without a timestamp, was deprecated in [Runnel 2.4](/changelog/v2-4-webhook-signatures/) and is removed in 3.0. Switch to `Runnel-Signature` if you still read the old one.

## Testing locally

`runnel dev` sends webhooks too. Set the endpoint in `runnel.config.ts` under `webhooks: [{ url: 'http://localhost:3000/runnel-webhook', events: ['run.failed'] }]`, and the local server signs them with the secret `rwhsec_dev`. To see what a failure looks like, throw `runnel.fatal('test')` from a job and watch the request arrive.
