Verifying signatures
Every webhook delivery is signed so you can confirm it really came from Service and was not tampered with or replayed. Always verify the signature before acting on a payload.
The Service-Signature header
Section titled “The Service-Signature header”Each request carries a Service-Signature header with two comma-separated
fields:
Service-Signature: t=1719515400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Field | Meaning |
|---|---|
t |
The Unix timestamp (seconds) when the signature was generated. |
v1 |
The signature: a hex HMAC-SHA256. |
How the signature is computed
Section titled “How the signature is computed”The v1 signature is the HMAC-SHA256 of the string "{t}.{body}" — the
timestamp, a literal dot (.), then the raw request body — keyed with your
endpoint’s signing secret (whsec_…):
v1 = HMAC_SHA256(secret = whsec_…, message = "{t}.{raw_request_body}")To verify, recompute that HMAC and compare it to v1 in constant time.
- Extract
tandv1from theService-Signatureheader. - Reject the request if
tis more than 5 minutes away from the current time (replay protection). - Compute
HMAC-SHA256("{t}.{raw_body}", secret). - Compare it to
v1using a constant-time comparison.
Node.js (Express)
Section titled “Node.js (Express)”import crypto from "node:crypto";import express from "express";
const TOLERANCE_SECONDS = 5 * 60;
function verifyServiceSignature(rawBody, header, secret) { // header looks like: "t=1719515400,v1=abc123..." const parts = Object.fromEntries( header.split(",").map((kv) => kv.split("=")), ); const timestamp = parts.t; const signature = parts.v1; if (!timestamp || !signature) return false;
// 1. Replay protection: reject stale timestamps. const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (ageSeconds > TOLERANCE_SECONDS) return false;
// 2. Recompute the HMAC over "{t}.{rawBody}". const expected = crypto .createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`, "utf8") .digest("hex");
// 3. Constant-time compare. const a = Buffer.from(expected); const b = Buffer.from(signature); return a.length === b.length && crypto.timingSafeEqual(a, b);}
const app = express();
app.post( "/webhooks/service", express.raw({ type: "application/json" }), // keep the RAW body as a Buffer (req, res) => { const rawBody = req.body.toString("utf8"); const ok = verifyServiceSignature( rawBody, req.get("Service-Signature") ?? "", process.env.SERVICE_WEBHOOK_SECRET, // your whsec_... value ); if (!ok) return res.status(400).send("invalid signature");
const event = JSON.parse(rawBody); // Deduplicate on event.id, then process asynchronously. res.status(200).send("ok"); },);Python (Flask)
Section titled “Python (Flask)”import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 5 * 60
def verify_service_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p) timestamp, signature = parts.get("t"), parts.get("v1") if not timestamp or not signature: return False
# 1. Replay protection. if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS: return False
# 2. Recompute the HMAC over "{t}.{raw_body}" (raw bytes). signed_payload = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
# 3. Constant-time compare. return hmac.compare_digest(expected, signature)
app = Flask(__name__)
@app.post("/webhooks/service")def handle_webhook(): raw_body = request.get_data() # raw bytes, before parsing if not verify_service_signature( raw_body, request.headers.get("Service-Signature", ""), os.environ["SERVICE_WEBHOOK_SECRET"], # your whsec_... value ): abort(400)
event = request.get_json() # Deduplicate on event["id"], then process asynchronously. return "", 200Secret rotation
Section titled “Secret rotation”Rotating an endpoint’s secret replaces it at once. There is no overlap window: from the rotation onward every delivery is signed with the new secret alone, and a signature computed with the old one stops matching.
The header therefore carries exactly one v1= field, and the parsers above are
complete as written — you never have to accept a second signature. A retry is
signed when it is sent rather than when the event was built, so an attempt made
after a rotation also carries the new secret.
Rotate when your consumer can take the new secret immediately: reveal it in the back office, deploy it, and expect deliveries made during the swap to fail their signature check and arrive again on the retry schedule.