[relaybell docs › Developers › Webhooks](https://relaybell.com/docs/webhooks) · [All docs as Markdown](https://relaybell.com/docs.md) · [Index for AI assistants](https://relaybell.com/llms.txt)

# Webhooks

> Outbound webhooks: events, signatures and how to verify them, retries, the delivery log, resend, test events and auto-disable.

relaybell can tell your own server when something happens. Add a webhook in **Settings → Developers → Webhooks**, or with the API:

```bash
curl -X POST https://relaybell.com/api/v1/webhooks \
  -H "Authorization: Bearer rb_live_xxxxx" -H "Content-Type: application/json" \
  -d '{ "url": "https://yourapp.example/webhooks/relaybell", "events": ["notification.clicked"] }'
```

It answers `201 { "webhook": { "id", "url", "events", "secret", "enabled", "created_at" } }`. The `url` must start with `http://` or `https://` (`400 invalid_url`, on `PUT` too); a webhook pointing at a private or internal address (`localhost`, `10.x`, `192.168.x` …) is saved, but every delivery to it is refused, its `last_status` shows `0` and its delivery log says why — to test on your own machine, expose the listener on a public HTTPS URL (a tunnel). The `secret` (starts with `whsec_`) is shown only in this answer — store it. An empty `events` list means every event; names that aren't in the table below are dropped, so a list of only unknown names also means every event. `GET /api/v1/webhooks` lists your webhooks with each one's `last_status` and `last_fired_at` (the last attempt) and its health: `consecutive_failures`, `failing_since`, `disabled_reason` and `disabled_at` (see [When a webhook keeps failing](https://relaybell.com/docs/webhooks#webhook-auto-disable)). `PUT /api/v1/webhooks/:id` changes `url`, `events` or `enabled`; `DELETE /api/v1/webhooks/:id` removes one, with its delivery log.

Each event is a `POST` with a JSON body and these headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Relaybell-Event` | The event name, e.g. `notification.clicked`. |
| `X-Relaybell-Delivery` | The delivery id (a UUID): one per event and webhook, **the same on every retry and resend**. Keep the ones you've handled and skip repeats. |
| `X-Relaybell-Webhook-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` — the signature to check (see [Verify a webhook signature](https://relaybell.com/docs/webhooks#webhook-verify)). |
| `X-Relaybell-Signature` | `sha256=<hex HMAC-SHA256 of the body>` — the original signature, still sent unchanged so existing integrations keep working. It has no timestamp; new code should check `v1`. |

```json
{
  "event": "notification.clicked",
  "data": { "notificationId": "5b0c2f7e-1d7a-4c3e-9a51-2f8e6b1d0c42", "variant": null },
  "projectId": "YOUR_PROJECT_ID",
  "at": "2026-10-14T09:31:12.345Z"
}
```

| Event | When it's sent, and its `data` |
| --- | --- |
| `subscriber.created` | A new browser subscribed. `{ subscriberId, endpointId, externalId, device, country }` — `externalId` is `null` when not known. |
| `subscriber.unsubscribed` | A visitor turned notifications off through your site (`RelayBell.unsubscribe()`). Subscriptions a browser drops by itself are removed without this event. `{ subscriberId, endpoint }` |
| `notification.sent` | A send finished going out. `{ notificationId, recipients, sent, failed, pruned }` |
| `notification.clicked` | Someone clicked a notification. `{ notificationId, variant }` — `variant` is `"a"` or `"b"` for an A/B send, otherwise `null`. |
| `webhook.test` | Only sent when you ask for a test event (dashboard **Send test event**, or `POST /api/v1/webhooks/:id/test`), to that one webhook. `{ webhookId, message }` |

**Retries.** Answer within 10 seconds with any `2xx` status, and do slow work afterwards. Anything else — no answer in time, a connection or TLS error, any other status — is a failed attempt, and relaybell tries again up to 5 more times: about **1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours** after each failure (a little earlier or later, so retries don't all arrive at once) — roughly 10½ hours in all. Every attempt sends the same body and the same `X-Relaybell-Delivery`, with a new timestamp and signature. Answer `410 Gone` when the receiving end no longer exists (for example a deleted Zap): relaybell stops retrying and turns the webhook off, and the account owner gets an email. A `410` from a resend or a test event only ends that attempt. Redirects are followed (up to 4, never to a private address). Because of retries, the same event can arrive more than once, and later events can arrive first: skip a delivery id you've already handled, and use `at` for the time the event happened.

## Delivery log, resend and test events {#webhook-deliveries}

Every delivery and the result of its last attempt are kept for 30 days. In the dashboard: **Settings → Developers → Webhooks → Deliveries**. With the API, reading the log, resend and test need `webhooks:write` (the log holds event data such as subscriber ids), and together they are limited to 30 a minute per website (`429 rate_limited`). An id that isn't one of this website's answers `404 not_found`.

| Route | What it does |
| --- | --- |
| **GET** `/api/v1/webhooks/:id/deliveries?limit&cursor` | The webhook's deliveries, newest first, 25 a page (`limit` up to 100). For the next page, send the `nextCursor` you got as `cursor`; it is `null` on the last page (`400 invalid_cursor` for one this API didn't return). Answers `{ deliveries: [{ id, event, status, attempts, last_status_code, duration_ms, error, created_at, last_attempt_at, next_attempt_at, delivered_at }], nextCursor }`. |
| **GET** `/api/v1/webhooks/:id/deliveries/:deliveryId` | `{ delivery }` with everything above plus `request_body` (exactly what was signed and sent, up to 64 KB — `request_truncated` is `true` when it was longer), `request_headers` (the last attempt's headers, signatures included; never your secret) and `response_body` (the first 2 KB of your server's last answer). |
| **POST** `/api/v1/webhooks/:id/deliveries/:deliveryId/resend` | `202 { delivery }`: one more attempt now, with the same delivery id and body and a fresh timestamp and signature — no retries after it. `409 delivery_in_progress` while an attempt is already waiting to run; `409 body_too_large` for an event over 64 KB. |
| **POST** `/api/v1/webhooks/:id/test` | `202 { delivery }`: sends a `webhook.test` event to this webhook once, no retries — even when the webhook is turned off or doesn't subscribe to anything else. Follow its result with the delivery routes above. |

`status` is `pending` (waiting for its first attempt, or a resend or test waiting to run), `retrying` (an attempt failed; `next_attempt_at` says when the next one is), `succeeded` (the last attempt got a `2xx`) or `failed` (every attempt failed, your server answered `410`, or the webhook was turned off before the next try). `attempts` counts every attempt, resends included. `last_status_code` is `null` when no answer came back; `error` then says why, in plain words.

## When a webhook keeps failing {#webhook-auto-disable}

relaybell turns a webhook off when **50 deliveries in a row** have failed (all their attempts), or when **every attempt to reach it has failed for 3 days**. The account owner gets one email about it, and `GET /api/v1/webhooks` shows `enabled: false` with `disabled_reason` `"failed_50_in_a_row"` or `"failing_for_3_days"` and `disabled_at`. While it's off, no new events are sent to it and retries that were waiting stop. Until then, `failing_since` (the first failure since the last success) and `consecutive_failures` show how it's going; any success sets them back to `null` and `0`. A `410` answer, resends and test events never count toward turning it off (a success from one of them still clears the count). Once your server works again, turn the webhook back on in the dashboard or with `PUT /api/v1/webhooks/:id { "enabled": true }`, which clears the count. Events from while it was off aren't sent later.

## Verify a webhook signature {#webhook-verify}

`X-Relaybell-Webhook-Signature` is `t=<unix seconds>,v1=<signature>`. `v1` is the lowercase hex HMAC-SHA256 of `<t>.<raw body>` — the timestamp, a dot, then the exact bytes of the request body — keyed with your webhook secret: the whole string, `whsec_` prefix included, as text. To check a request:

1. Refuse to verify at all when your secret is missing or empty — fail closed, never with an empty key.
2. Split the header on commas into `key=value` pairs and take `t` and `v1`; ignore other keys (a later version may add one). A missing or malformed header fails the check.
3. Reject the request when `t` is more than **5 minutes** (300 seconds) away from your clock: a captured request can't be replayed later.
4. Compute the HMAC over the received bytes — not JSON you parsed and re-encoded — and compare it with `v1` in constant time.
5. Skip the event when you've already handled its `X-Relaybell-Delivery`.

```js tab="Node.js"
// Node.js (Express)
const crypto = require("crypto");

// The parsed event, or null when the request didn't come from relaybell.
function verifyRelaybell(rawBody, header, secret, toleranceSeconds = 300) {
  if (!secret) throw new Error("RELAYBELL_WEBHOOK_SECRET is not set"); // fail closed
  if (!Buffer.isBuffer(rawBody) || typeof header !== "string") return null;
  const fields = {};
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i > 0) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim();
  }
  const { t, v1 } = fields;
  if (!/^\d+$/.test(t || "") || !/^[0-9a-f]{64}$/.test(v1 || "")) return null;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return null;
  const expected = crypto.createHmac("sha256", secret).update(t + ".").update(rawBody).digest();
  if (!crypto.timingSafeEqual(Buffer.from(v1, "hex"), expected)) return null; // both 32 bytes
  return JSON.parse(rawBody.toString("utf8"));
}

// express.raw keeps the exact bytes relaybell signed.
app.post("/webhooks/relaybell", express.raw({ type: "application/json" }), (req, res) => {
  const event = verifyRelaybell(req.body, req.get("X-Relaybell-Webhook-Signature"), process.env.RELAYBELL_WEBHOOK_SECRET);
  if (!event) return res.sendStatus(401);
  res.sendStatus(200);
  // …then handle event.event / event.data, once per X-Relaybell-Delivery
});
```

```python tab="Python"
# Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)

def verify_relaybell(raw_body: bytes, header: str, secret: str, tolerance: int = 300):
    """The parsed event, or None when the request didn't come from relaybell."""
    if not secret:
        raise RuntimeError("RELAYBELL_WEBHOOK_SECRET is not set")  # fail closed
    fields = {}
    for part in (header or "").split(","):
        key, sep, value = part.partition("=")
        if sep:
            fields[key.strip()] = value.strip()
    t, v1 = fields.get("t", ""), fields.get("v1", "")
    if not (t.isascii() and t.isdigit()) or abs(time.time() - int(t)) > tolerance:
        return None
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    # compare_digest is constant-time and returns False (no error) when the lengths differ.
    if not hmac.compare_digest(expected.encode(), v1.encode()):
        return None
    return json.loads(raw_body)

@app.post("/webhooks/relaybell")
def relaybell_webhook():
    event = verify_relaybell(
        request.get_data(),  # the exact bytes relaybell signed
        request.headers.get("X-Relaybell-Webhook-Signature", ""),
        os.environ.get("RELAYBELL_WEBHOOK_SECRET", ""),
    )
    if event is None:
        abort(401)
    return "", 200
```

```php tab="PHP"
<?php // PHP
$raw    = file_get_contents('php://input');               // the exact bytes relaybell signed
$header = $_SERVER['HTTP_X_RELAYBELL_WEBHOOK_SIGNATURE'] ?? '';
$secret = getenv('RELAYBELL_WEBHOOK_SECRET');
if (!is_string($secret) || $secret === '') {              // never verify with an empty key
    http_response_code(500);
    exit;
}
$fields = [];
foreach (explode(',', $header) as $part) {
    $kv = explode('=', $part, 2);
    if (count($kv) === 2) {
        $fields[trim($kv[0])] = trim($kv[1]);
    }
}
$t  = $fields['t'] ?? '';
$v1 = $fields['v1'] ?? '';
if (!ctype_digit($t) || abs(time() - (int) $t) > 300) {   // 5-minute tolerance
    http_response_code(401);
    exit;
}
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!hash_equals($expected, $v1)) {                       // constant-time; false for any mismatch
    http_response_code(401);
    exit;
}
$event = json_decode($raw, true);                         // ['event' => ..., 'data' => [...]]
http_response_code(200);
```

The [server SDKs](https://relaybell.com/docs/server-sdks) (1.2.0 and newer) do all of this in one call: Node `verifyWebhook({ payload, headers, secret })`, Python `verify_webhook(payload, headers, secret)`, PHP `Relaybell\Webhook::verify($payload, $headers, $secret)`.

> **Note:** **The older header.** `X-Relaybell-Signature: sha256=<hex HMAC-SHA256 of the raw body>` (same secret) is still sent with every attempt, so code that checks it keeps working. It signs the body only, with no timestamp, so a captured request could be sent to you again later; move to `v1` when you can.
