> ## Documentation Index
> Fetch the complete documentation index at: https://docs.surfais.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify webhook signatures

> Check the HMAC-SHA256 signature and timestamp on every webhook before you trust its body, with Node.js and Python receivers.

Your webhook URL is reachable from the public internet, so anyone can send it a request. Every request Surfais sends is signed with your endpoint's secret. Verify the signature before you parse the body or act on it, and reject anything that fails.

You receive the secret once, when you [register the endpoint](/api/webhooks/overview) or rotate its secret. It starts with `whsec_`.

## Headers on every attempt

| Header                | Value                                                                                                              |
| --------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `X-Surfais-Signature` | `v1=` followed by 64 lowercase hexadecimal characters: the signature described below.                              |
| `X-Surfais-Timestamp` | Unix time in seconds when **this attempt** was sent. A retry carries a later timestamp than the attempt before it. |
| `X-Surfais-Event-Id`  | The event's id. It equals `id` in the body and stays the same on every retry.                                      |
| `Content-Type`        | `application/json`                                                                                                 |
| `User-Agent`          | `Surfais-Webhooks/1`                                                                                               |

Only the signature proves that a request came from Surfais. Do not authenticate on `User-Agent` or on the sender's address.

## How the signature is computed

```text theme={null}
signature = hex( HMAC-SHA256( key, message ) )

key       = your endpoint secret as UTF-8 bytes, including the "whsec_" prefix
message   = <X-Surfais-Timestamp> + "." + <raw request body>
```

The header is `X-Surfais-Signature: v1=<signature>`.

To verify a request, make these checks in this order and reject the request as soon as one fails:

1. `X-Surfais-Signature` and `X-Surfais-Timestamp` are both present.
2. The timestamp is a plain run of digits.
3. The timestamp is within the tolerance of your own clock, in either direction.
4. The signature starts with `v1=`, and the rest is exactly 64 lowercase hexadecimal characters.
5. The signature you compute over the **raw bytes you received** equals the one presented. Compare in constant time.

The tolerance is 300 seconds. Because the timestamp is part of the signed message, a captured request cannot be replayed once it is older than that.

Answer `401` when a check fails.

<Warning>
  Compute the signature over the raw request body, byte for byte. Never parse the JSON and serialise it again first: key order, spacing and number formatting change, and the signature no longer matches.
</Warning>

## Node.js receiver

A complete Express receiver. `express.raw()` keeps the body as the `Buffer` that arrived on the wire.

```js receiver.mjs theme={null}
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

// The whsec_… secret you were shown when you registered the endpoint, prefix included.
const SECRET = process.env.SURFAIS_WEBHOOK_SECRET;
if (!SECRET) throw new Error("Set SURFAIS_WEBHOOK_SECRET");

const TOLERANCE_SECONDS = 300;

function verify({ secret, signatureHeader, timestampHeader, rawBody, nowSeconds }) {
  if (!signatureHeader || !timestampHeader) return false;
  if (!/^\d{1,12}$/.test(timestampHeader)) return false;
  const ts = Number(timestampHeader);
  if (Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) return false;
  const prefix = "v1=";
  if (!signatureHeader.startsWith(prefix)) return false;
  const presented = signatureHeader.slice(prefix.length);
  if (!/^[0-9a-f]{64}$/.test(presented)) return false;
  const expected = createHmac("sha256", Buffer.from(secret, "utf8"))
    .update(`${ts}.`, "utf8")
    .update(rawBody)
    .digest("hex");
  return timingSafeEqual(Buffer.from(presented, "hex"), Buffer.from(expected, "hex"));
}

function handleEvent(rawBody) {
  // Parse only after the signature has been verified.
  const event = JSON.parse(rawBody.toString("utf8"));
  // Your processing goes here. In production, put the event on a durable queue.
  console.log(`received ${event.type} ${event.id}`);
}

const app = express();

// Do not put express.json() in front of this route: it replaces the raw bytes with a parsed object.
app.post("/webhooks/surfais", express.raw({ type: "application/json" }), (req, res) => {
  const ok =
    Buffer.isBuffer(req.body) &&
    verify({
      secret: SECRET,
      signatureHeader: req.get("X-Surfais-Signature"),
      timestampHeader: req.get("X-Surfais-Timestamp"),
      rawBody: req.body,
      nowSeconds: Math.floor(Date.now() / 1000),
    });
  if (!ok) {
    res.status(401).end();
    return;
  }

  // Acknowledge first, process afterwards.
  res.status(204).end();
  setImmediate(() => {
    try {
      handleEvent(req.body);
    } catch (err) {
      console.error("webhook processing failed", err);
    }
  });
});

app.listen(3000);
```

## Python receiver

The same checks in the same order, with Flask. `request.get_data()` returns the body as the bytes that arrived.

```python receiver.py theme={null}
import hashlib
import hmac
import json
import os
import re
import threading
import time

from flask import Flask, request

# The whsec_… secret you were shown when you registered the endpoint, prefix included.
SECRET = os.environ["SURFAIS_WEBHOOK_SECRET"]

TOLERANCE_SECONDS = 300

app = Flask(__name__)


def verify(secret, signature_header, timestamp_header, raw_body, now_seconds):
    if not signature_header or not timestamp_header:
        return False
    if not re.fullmatch(r"[0-9]{1,12}", timestamp_header):
        return False
    ts = int(timestamp_header)
    if abs(now_seconds - ts) > TOLERANCE_SECONDS:
        return False
    prefix = "v1="
    if not signature_header.startswith(prefix):
        return False
    presented = signature_header[len(prefix):]
    if not re.fullmatch(r"[0-9a-f]{64}", presented):
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{ts}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(bytes.fromhex(presented), bytes.fromhex(expected))


def handle_event(raw_body):
    # Parse only after the signature has been verified.
    event = json.loads(raw_body)
    # Your processing goes here. In production, put the event on a durable queue.
    print(f"received {event['type']} {event['id']}")


@app.route("/webhooks/surfais", methods=["POST"])
def surfais_webhook():
    raw_body = request.get_data()  # bytes, exactly as received
    ok = verify(
        SECRET,
        request.headers.get("X-Surfais-Signature"),
        request.headers.get("X-Surfais-Timestamp"),
        raw_body,
        int(time.time()),
    )
    if not ok:
        return "", 401

    # Acknowledge first, process afterwards.
    threading.Thread(target=handle_event, args=(raw_body,), daemon=True).start()
    return "", 204


if __name__ == "__main__":
    app.run(port=3000)
```

## Test your implementation

Check your own verifier against this known answer before you connect it to live traffic.

| Input              | Value                                                                              |
| ------------------ | ---------------------------------------------------------------------------------- |
| Secret             | `whsec_` followed by 43 `a` characters                                             |
| Timestamp          | `1756800000`                                                                       |
| Body               | `{"id":"evt_01ARZ3NDEKTSV4RRFFQ69G5FAV","type":"webhook.test","api_version":"v1"}` |
| Expected signature | `22a2582601711a5cad06220ba46b0fe8b21c3c0b4a139a2b0834626bd72c8261`                 |

The body has no spaces and no trailing newline. The timestamp is in the past, so set your clock argument to a value near it when you run the check. Your verifier must accept this request. It must reject it when you change one byte of the body, use a different secret, or move your clock more than the tolerance away from the timestamp.

## Common pitfalls

<Warning>
  **Body parsers that re-serialise JSON.** Express `express.json()`, Next.js route handlers that call `request.json()`, and API gateways that parse and rebuild the payload all hand you a body that is no longer the bytes Surfais signed. Read the raw body instead: `express.raw()` in Express, `await request.arrayBuffer()` in a Next.js route handler, and a pass-through setting on a gateway.
</Warning>

<Warning>
  **Trimming or decoding the body.** Do not trim whitespace, normalise line endings or change the character encoding before you compute the signature. Hash the bytes as they arrived.
</Warning>

<Warning>
  **Using the secret without its prefix.** The key is the whole string you were shown, `whsec_` included, as UTF-8. Do not strip the prefix and do not base64-decode the rest.
</Warning>

<Warning>
  **Clock skew.** The timestamp check compares against your server's clock. Keep the clock synchronised. A server that drifts past the tolerance rejects every request.
</Warning>

<Note>
  The timestamp and the signature change on every attempt, because each attempt is signed when it is sent. The body and `X-Surfais-Event-Id` do not change. Verify each attempt against its own headers.
</Note>

After you [rotate a secret](/api/webhooks/overview#rotate-the-secret), attempts that start after the call are signed with the new secret. An attempt already in flight finishes with the old one. An attempt can reach you signed with the new secret before your receiver has loaded it. It is rejected and retried on the schedule, so nothing is lost. Rotate at a quiet time, load the new secret at once, and keep accepting the old one briefly for attempts already in flight.

## Respond fast

Answer with a `2xx` status within 10 seconds, then do your processing. That budget covers the whole attempt, including connecting to your server.

* **Only the status code matters.** Any `2xx` counts as delivered. The response body is ignored, apart from the first bytes, which are kept in your [delivery log](/api/webhooks/delivery#the-delivery-log).
* **Any `3xx` is a failure.** Surfais never follows a redirect with a signed body. Point the endpoint URL at its final location.
* **Anything else is a failure too.** A `4xx`, a `5xx`, a timeout or a connection error is retried on the [retry schedule](/api/webhooks/delivery#retry-schedule).

Because delivery is at-least-once, your receiver can see the same event more than once. [Dedupe on the event id](/api/webhooks/delivery#at-least-once-delivery).
