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
FieldMeaning
tThe Unix timestamp (seconds) when the signature was generated.
v1The 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

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.