Webhook Signatures
Verify HMAC-SHA256 signatures on incoming webhook deliveries.
Every outbound webhook delivery is signed with HMAC-SHA256 using the endpoint's signing secret. Verify the signature before trusting the payload. Without verification, anyone who knows your endpoint URL could POST arbitrary JSON to it.
Headers
SurfacedBy sets two signature headers on every delivery:
X-SurfacedBy-Timestamp- Unix epoch seconds at signing time.X-SurfacedBy-Signature-t=<timestamp>,v1=<hex-hmac-sha256>. Thetvalue is the same timestamp as the header above.
The signature is computed over the timestamp, a single dot, and the raw request body: HMAC-SHA256(secret, timestamp + "." + raw_body), hex-encoded in lowercase. The key is the signing secret exactly as shown when you created the endpoint (a 64-character hex string), used as text. Do not hex-decode it.
Compute the HMAC over the raw bytes you received, not over JSON you parsed and re-serialised; any difference in whitespace or key order invalidates the signature.
Each delivery attempt is signed when it is sent, so a retry or a replay carries a fresh timestamp and signature.
Verification algorithm
- Read the
X-SurfacedBy-Signatureheader and parse out thev1=value. - Read the
X-SurfacedBy-Timestampheader. - Reject the request if the timestamp is further from your server's clock than your tolerance window. SurfacedBy does not impose a window; 5 minutes is a sensible default. This limits replay of a captured request.
- Compute
HMAC-SHA256(secret, timestamp + "." + raw_body)and hex-encode it. - Compare the computed value to the
v1=value with a constant-time comparison.
If any step fails, return 400 or 401 and do not process the payload. A non-2xx response counts as a failed attempt and is retried, so fix verification problems before they exhaust the retries.
Python
Pass the undecoded request body, for example request.get_data() in Flask or await request.body() in FastAPI or Starlette. Header lookups must be case-insensitive in your framework, or normalise the names first.
Node.js
rawBody must be a Buffer of the unparsed body, for example from express.raw({ type: "application/json" }) on the webhook route. Node lowercases incoming header names.
PHP
Read the body with file_get_contents('php://input'). Header name casing depends on your server; normalise the keys of $headers if needed.
Storing the secret
The signing secret is shown once, when you create the endpoint, and cannot be retrieved later. Store it in a secret manager. There is no rotate action. To change a secret, create a new endpoint with the same URL and events, have your receiver accept a signature from either secret, delete the old endpoint, then drop the old secret. While both endpoints exist, each event is delivered twice with the same event id, so deduplicate on it.