Skip to content

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.

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.

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.

  1. Extract t and v1 from the Service-Signature header.
  2. Reject the request if t is more than 5 minutes away from the current time (replay protection).
  3. Compute HMAC-SHA256("{t}.{raw_body}", secret).
  4. Compare it to v1 using a constant-time comparison.
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");
},
);
import hashlib
import hmac
import os
import 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 "", 200

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.