Runnel 2.8: replay any workflow from a failed step.Read the release

Skip to content
Runnel
Documentation

    Docs menu

    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

    EventSent when
    run.succeededA run finished successfully.
    run.failedA run failed and has no attempts left, or threw runnel.fatal().
    run.retryingAn 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:

    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 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.

    Edit this page