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.
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, without its steps.
{
"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:
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
tis 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
idto skip duplicates, and the run’sstatusrather 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-Signatureheader, which signed the body without a timestamp, was deprecated in Runnel 2.4 and is removed in 3.0. Switch toRunnel-Signatureif 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.