Webhooks
Webhooks: receiving and verifying
Delivery headers, verifying the signature in Node and Python, retries, batching and other delivery behavior.
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Ruuster-Event-Id |
The event id |
Ruuster-Event-Kind |
The event kind |
Ruuster-Delivery-Id |
Unique per attempt. Quote it to support |
Ruuster-Signature |
t=<unix seconds>,v1=<hex signature> |
User-Agent |
Ruuster-Webhooks/1.0 |
Verify every request. The signature is HMAC-SHA256 of <t>.<raw body> keyed with your
subscription's secret (whsec_…). Compute it over the raw bytes before parsing, compare in constant
time, and reject a t more than 5 minutes from your clock. During a secret rotation the header can
carry two v1= values; accept the request if either matches.
Node.js (Express):
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/ruuster-webhook", express.raw({ type: "application/json" }), (req, res) => {
const parts = req.get("Ruuster-Signature")?.split(",") ?? [];
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.RUUSTER_WEBHOOK_SECRET)
.update(`${t}.${req.body}`)
.digest("hex");
const ok = sigs.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
queue.push(event); // do the work later
res.sendStatus(200);
});
Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["RUUSTER_WEBHOOK_SECRET"].encode()
@app.post("/ruuster-webhook")
def ruuster_webhook():
parts = request.headers.get("Ruuster-Signature", "").split(",")
t = int(next((p[2:] for p in parts if p.startswith("t=")), "0"))
sigs = [p[3:] for p in parts if p.startswith("v1=")]
if abs(time.time() - t) > 300:
abort(400)
expected = hmac.new(SECRET, f"{t}.".encode() + request.get_data(), hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, s) for s in sigs):
abort(401)
queue.put(request.get_json()) # do the work later
return "", 200
Delivery behavior
- Answer with any
2xxwithin 10 seconds, then do the work. Timeouts, redirects and other statuses are failures. - At least once: the same event can arrive twice. De-duplicate on
id. - No ordering: use
occurredAt. - Retries: failed deliveries are retried with increasing delays (about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours) for roughly a day, then marked failed. You can still replay them for 30 days.
- Automatic disable: a subscription that fails every delivery for about 24 hours is disabled. It
shows
status: "disabled"inGET /webhooks, and Ruuster is alerted. Fix the endpoint, setstatusback toactive, then replay the gap. - Batching: when a subscription would get more than one event per second, events arrive grouped as
{ "id": "bat_…", "kind": "batch", "apiVersion": "v1", "count": n, "events": [ … ] }, up to 100 events or 256 KB, signed the same way. Handle both shapes. - Volume cap: 50,000 deliveries per brokerage per day; above that, batches only.