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”Endpoint secrets can be rotated with an overlap window, during which deliveries may be signed with either the old or the new secret. When you rotate, accept a signature that matches either secret until the old one is retired.