# Webhooks: receiving and verifying

Source: https://help.ruuster.com/api/webhook-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):

```js
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):

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