Skip to content

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:

Shell
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). 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:

HeaderValue
Content-Typeapplication/json
X-Relaybell-EventThe event name, e.g. notification.clicked.
X-Relaybell-DeliveryThe 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-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> — the signature to check (see Verify a webhook signature).
X-Relaybell-Signaturesha256=<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"
}
EventWhen it's sent, and its data
subscriber.createdA new browser subscribed. { subscriberId, endpointId, externalId, device, country } — externalId is null when not known.
subscriber.unsubscribedA visitor turned notifications off through your site (RelayBell.unsubscribe()). Subscriptions a browser drops by itself are removed without this event. { subscriberId, endpoint }
notification.sentA send finished going out. { notificationId, recipients, sent, failed, pruned }
notification.clickedSomeone clicked a notification. { notificationId, variant } — variant is "a" or "b" for an A/B send, otherwise null.
webhook.testOnly 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

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.

RouteWhat it does
GET /api/v1/webhooks/:id/deliveries?limit&cursorThe 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/resend202 { 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/test202 { 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

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

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.
// 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
});

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