> This is the Markdown of https://backed.giving/developers/guides/webhook-signatures, a page of the Backed developer documentation. Every page is listed at https://backed.giving/developers/llms.txt.

# Webhook signature verification

Check the Backed-Signature header on every webhook delivery with HMAC-SHA256, in Node.js or Python, and answer in time.

Backed sends each event an endpoint is subscribed to as an HTTPS `POST` with a JSON body. Anyone could send a request to your endpoint’s address, so check the signature before you trust the body. Set endpoints up on the `Webhooks` tab: see [Webhooks](https://admin.backed.giving/help/integrations/webhooks) in the Help Centre. Every event type is in the [Event catalogue](https://backed.giving/developers/events.md).

## The headers

Delivery headers:

| Header | What it holds |
| --- | --- |
| `Backed-Signature` | `t=<unix seconds>,v1=<signature>`: the time it was signed and the hex HMAC-SHA256 of it. For 24 hours after the secret is rolled there are two `v1=` values, one for each secret. |
| `Backed-Event-Id` | The event’s id, the body’s `id`. The same on every retry and replay: de-duplicate on it. |
| `Backed-Event-Type` | The event type, the body’s `type`. |

## Check the signature

1. Read `t` and every `v1=` value from the `Backed-Signature` header.
2. Build the signed string: `t`, a full stop, then the raw request body exactly as it arrived. Parse nothing first: re-serialised JSON is not the same bytes.
3. Compute the HMAC-SHA256 of that string, keyed with your endpoint’s signing secret (the whole value, `whsec_` included), as lowercase hex.
4. Compare it with each `v1=` value in constant time. One match is enough.
5. Reject the delivery if `t` is more than five minutes old, so a captured delivery cannot be replayed later.

Node.js:

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';

/**
 * Whether a delivery really came from Backed. `header` is the Backed-Signature header,
 * `rawBody` the request body exactly as received (a Buffer or string, before any JSON parsing),
 * `secret` the endpoint's signing secret, whsec_ included.
 */
export function verifyBackedSignature(header, rawBody, secret, toleranceSeconds = 300) {
  let t = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const [key, value] = part.trim().split('=', 2);
    if (key === 't' && /^[0-9]{1,12}$/.test(value)) t = Number(value);
    else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
  }
  if (t === null || signatures.length === 0) return false;
  if (Math.floor(Date.now() / 1000) - t > toleranceSeconds) return false;
  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex');
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

/**
 * The route's handler. `enqueue(event)` is yours: store the event durably (a queue or a table,
 * de-duplicated on event.id, the Backed-Event-Id header) and resolve once it is stored.
 * Do the slow work from there, after answering, so the answer comes within 10 seconds.
 */
export function backedWebhookHandler(secret, enqueue) {
  return async (req, res) => {
    const header = req.get('Backed-Signature') ?? '';
    if (!verifyBackedSignature(header, req.body, secret)) return res.sendStatus(400);
    try {
      await enqueue(JSON.parse(req.body.toString('utf8')));
    } catch {
      return res.sendStatus(500); // not stored: a non-2xx answer makes Backed retry it
    }
    return res.sendStatus(204); // stored: a 2xx is final, Backed will not send it again
  };
}

// Express: read the raw body for this route, not parsed JSON.
const app = express();
app.post(
  '/backed/webhooks',
  express.raw({ type: 'application/json' }),
  backedWebhookHandler(process.env.BACKED_WEBHOOK_SECRET, enqueue),
);
```

Python:

```python
import hashlib
import hmac
import os
import re
import time

from flask import Flask, request


def verify_backed_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    """Whether a delivery really came from Backed.

    header is the Backed-Signature header, raw_body the request body exactly as received,
    secret the endpoint's signing secret, whsec_ included.
    """
    t = None
    signatures = []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        # ASCII only: str.isdigit() accepts "²", and a non-ASCII signature makes compare_digest raise.
        if key == "t" and re.fullmatch(r"[0-9]{1,12}", value):
            t = int(value)
        elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
            signatures.append(value)
    if t is None or not signatures:
        return False
    if int(time.time()) - t > tolerance:
        return False
    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)


# Flask: request.get_data() is the raw body.
app = Flask(__name__)


@app.post("/backed/webhooks")
def backed_webhook():
    header = request.headers.get("Backed-Signature", "")
    if not verify_backed_signature(header, request.get_data(), os.environ["BACKED_WEBHOOK_SECRET"]):
        return "", 400
    event = request.get_json()
    try:
        # Yours: store the event durably (a queue or a table, de-duplicated on event["id"]),
        # returning once it is stored. Do the slow work from there, after answering.
        enqueue(event)
    except Exception:
        return "", 500  # not stored: a non-2xx answer makes Backed retry it
    return "", 204  # stored: a 2xx is final, Backed will not send it again
```

## Answer in time

- Answer a 2xx status only once the event is safely stored, such as on a queue or in a table, and within 10 seconds: store it, answer, then do the slow work from the store. A 2xx is final: Backed treats the delivery as delivered and never sends it again, so an event you acknowledge and then lose is lost.
- If you cannot store the event, answer `500` (or any non-2xx status) so that Backed retries it.
- Anything else, or no answer within 10 seconds, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours. After the last it is `Failed`, and is sent again only if someone replays it.
- An endpoint that fails 50 deliveries in a row is disabled, and the Administrators who manage integrations are emailed.
- Deliveries about one record arrive in the order they happened.
- A retry or a replay carries the same event id, so a delivery may reach you more than once: handle each id once.

## What the body holds

The body names the event and the record it is about, with the record’s address in the API. It never carries form answers, bank details or contact details: read the record through the API, with the endpoint’s credential, for those. The payload’s shape is on the [Event catalogue](https://backed.giving/developers/events.md) page. `Send Test` on the endpoint’s page sends a signed `webhook.test` event, so you can check your verification before a real event arrives.
