Menu · Webhooks
Webhooks

Webhooks: receiving and verifying

Delivery headers, verifying the signature in Node and Python, retries, batching and other delivery behavior.

View as Markdown

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 2xx within 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" in GET /webhooks, and Ruuster is alerted. Fix the endpoint, set status back to active, 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.