Skip to main content
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 or rotate its secret. It starts with whsec_.

Headers on every attempt

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

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

Node.js receiver

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

Python receiver

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

Test your implementation

Check your own verifier against this known answer before you connect it to live traffic. 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

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.
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.
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.
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.
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.
After you rotate a 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.
  • 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.
Because delivery is at-least-once, your receiver can see the same event more than once. Dedupe on the event id.