Most production integrations run in both directions. You call APIs, and other services call you: a payment provider reports a charge, a source host reports a push, a form tool reports a submission. Those inbound calls are webhooks, and the endpoint that receives them is a public URL that anybody on the internet can POST to.
APIVerve itself does not send webhooks. Its endpoints are request and response, and nothing on the platform calls your server. But almost every system that calls APIVerve also receives webhooks from somewhere, and very often the webhook is what triggers the API call: a new signup arrives from your auth provider and you validate the email; a payment succeeds and you generate an invoice. A forged webhook in that chain spends your credits on attacker-chosen input and, worse, makes your system act on events that never happened.
This lesson builds a verifier for the most common signing scheme, tests it, and closes the gaps (raw bodies, replays, timing leaks) where real verifiers usually fail.
What a signature proves
The sender and you share a secret, typically shown once when you register the endpoint. For each delivery, the sender computes an HMAC (hash-based message authentication code) over the request and sends the result in a header. HMAC-SHA256 takes a secret and a message and produces a 32-byte value that only someone holding the secret could have produced for that exact message. Change one byte of the message, or use a different secret, and the output is unrelated.
You recompute the HMAC with your copy of the secret over what you received and compare. If they match, two things are true:
- Authenticity: the request came from someone who holds the secret.
- Integrity: the signed bytes were not changed in transit.
Two things are not covered by a bare HMAC. It says nothing about confidentiality (the body is readable; TLS is what protects it in transit). And it says nothing about freshness: a captured request with a valid signature stays valid forever, so an attacker who sees one delivery can replay it. That is why the schemes worth trusting sign a timestamp along with the body.
Signing schemes differ in detail between providers, so always implement the one your provider documents. Three common shapes:
| Provider style | Header | Signed message | Replay protection |
|---|---|---|---|
| Stripe | Stripe-Signature: t=<unix>,v1=<hex> | <t>.<raw body> | Timestamp inside the signed message |
| Slack | X-Slack-Signature: v0=<hex> plus X-Slack-Request-Timestamp | v0:<timestamp>:<raw body> | Timestamp inside the signed message |
| GitHub | X-Hub-Signature-256: sha256=<hex> | <raw body> | None in the signature; use the delivery id |
The rest of this lesson uses the Stripe-style t=...,v1=... format, because it carries everything a good scheme needs in one header, and many providers copy it.
The header, field by field
A Stripe-style header is a comma-separated list of key=value pairs:
t=1700000000,v1=559ce53eb4efe6d1069e5776295348f9902079cfaa716425b5f770682854f464tis the Unix time in seconds when the sender signed the delivery.v1is the hex HMAC-SHA256 of the string`${t}.${rawBody}`: the timestamp, a literal dot, then the body exactly as sent.- There may be more than one
v1. During a secret rotation the sender signs with both the old and new secret, and a delivery is valid if anyv1matches. Your parser must collect them all rather than keeping the last one. - Other schemes such as
v0may appear. Ignore any scheme you do not verify; never accept a delivery because an unknown scheme was present.
Because t is inside the signed message, an attacker cannot take an old delivery and put a fresh timestamp on it: changing t changes the HMAC. So you can safely reject anything whose t is too far from your clock, which turns "valid forever" into "valid for five minutes".
You can compute an expected signature with APIVerve's HMAC Generator, which is useful for building test fixtures and for checking your own implementation against an independent one. The message here is a timestamp, a dot and a small JSON body, exactly what a sender would sign:
curl -X POST "https://api.apiverve.com/v1/hmac" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"message":"1700000000.{\"id\":\"evt_1\",\"type\":\"order.paid\"}","secret":"whsec_test_123","algorithm":"sha256","encoding":"hex"}'data.hmac is the v1 value a sender would put in the header for t=1700000000 and this exact body.
Run the same thing locally and compare. They match byte for byte:
import { createHmac } from 'node:crypto';
const t = 1700000000;
const body = '{"id":"evt_1","type":"order.paid"}';
console.log(createHmac('sha256', 'whsec_test_123').update(`${t}.${body}`, 'utf8').digest('hex'));
// 559ce53eb4efe6d1069e5776295348f9902079cfaa716425b5f770682854f464Verify over the raw body
This is where most broken verifiers break. The signature covers the exact bytes the sender transmitted. If your framework parses the JSON before your handler runs and you re-serialise it with JSON.stringify, you are hashing a different string: whitespace disappears, é becomes é, 1.0 becomes 1, and the HMAC no longer matches, or worse, you "fix" it by loosening the check.
So the order is fixed: read raw bytes, verify, and only then parse. How you get the raw bytes depends on the framework:
// The raw parser must apply to this route BEFORE any app-wide express.json().
import express from 'express';
const app = express();
app.post('/webhooks/orders', express.raw({ type: 'application/json', limit: '256kb' }), (req, res) => {
const rawBody = req.body.toString('utf8'); // req.body is a Buffer here
// verify, then JSON.parse(rawBody)
});
app.use(express.json()); // everything elseexport default {
async fetch(request, env) {
const rawBody = await request.text(); // read once, as sent
// verify, then JSON.parse(rawBody)
},
};from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/orders")
def orders():
raw_body = request.get_data(as_text=True) # before request.json
# verify, then json.loads(raw_body)Next.js route handlers follow the Worker shape: await req.text(), never await req.json(), before verification. Whatever the framework, cap the body size. A verifier still has to read the body to hash it, and an unbounded body is a cheap way to tie up your server.
A handler runs const event = await req.json() and then verifies JSON.stringify(event) against the v1 signature. Deliveries fail intermittently. Why?
The verifier
Here is the complete check. It takes a helper, hmacSha256Hex(secret, message), so that the logic is plain JavaScript you can test without any crypto, and it takes the current time as an argument, so tests can freeze the clock. It returns a reason rather than a bare boolean, because "stale timestamp" and "bad signature" mean very different things in your logs.
// Node 18+, ESM.
import { createHmac } from 'node:crypto';
export const hmacSha256Hex = (secret, message) =>
createHmac('sha256', secret).update(message, 'utf8').digest('hex');
const TOLERANCE_SEC = 300;
// Compare two strings without returning early on the first difference.
function constantTimeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export function parseSignatureHeader(header) {
let t = null;
const v1 = [];
for (const part of String(header ?? '').split(',')) {
const eq = part.indexOf('=');
if (eq < 1) continue; // no key, or no '=' at all
const k = part.slice(0, eq).trim();
const v = part.slice(eq + 1).trim();
if (k === 't') t = v;
else if (k === 'v1') v1.push(v.toLowerCase());
}
return { t, v1 };
}
export function verifyWebhook(rawBody, header, secret, nowSec) {
const { t, v1 } = parseSignatureHeader(header);
if (t === null || !/^\d+$/.test(t)) return { ok: false, reason: 'missing or malformed timestamp' };
if (v1.length === 0) return { ok: false, reason: 'no v1 signature' };
const ts = Number(t);
if (Math.abs(nowSec - ts) > TOLERANCE_SEC) return { ok: false, reason: 'timestamp outside tolerance' };
const expected = hmacSha256Hex(secret, `${t}.${rawBody}`);
const match = v1.some((sig) => constantTimeEqual(sig, expected));
return match ? { ok: true, timestamp: ts } : { ok: false, reason: 'signature mismatch' };
}Each line is there for a reason:
- The tolerance is two-sided.
Math.absrejects timestamps from the future as well as the past. A sender's clock can drift, so five minutes in either direction is normal; a timestamp an hour ahead is not a clock problem. tmust be all digits.Number('')is0andNumber(' 12 ')is12. A strict pattern refuses anything you would not have produced yourself.- The signed message uses
tas received, the original string, not a re-formatted number. Same principle as the raw body: sign exactly what was sent. - Every
v1is tried, so deliveries keep verifying through the sender's secret rotation. - The comparison is constant-time. An ordinary
===on strings can return as soon as the first character differs, so in principle an attacker measuring response times could learn how many leading characters of a forged signature were right and build a valid one a character at a time. The loop always examines every character. Node also providescrypto.timingSafeEqual(bufA, bufB), which does the same for equal-length buffers and throws if the lengths differ, hence the length check before it.
Wiring it in is short, and the ordering is the point: nothing touches JSON.parse until the signature is good.
import { verifyWebhook } from './verify-webhook.mjs';
export async function handleOrders(rawBody, headers) {
const result = verifyWebhook(rawBody, headers['stripe-signature'], process.env.ORDERS_WEBHOOK_SECRET, Math.floor(Date.now() / 1000));
if (!result.ok) {
console.warn(JSON.stringify({ msg: 'webhook rejected', reason: result.reason }));
return { status: 400, body: 'invalid signature' }; // no detail for the caller
}
const event = JSON.parse(rawBody);
// ...dedupe and enqueue, below
return { status: 200, body: 'ok' };
}Log the reason; do not return it. Telling a caller "timestamp outside tolerance" versus "signature mismatch" helps an attacker more than it helps a legitimate sender, whose dashboard will show the failure anyway.
The same verifier in Python, where hmac.compare_digest is the standard library's constant-time comparison:
import hashlib
import hmac
import re
TOLERANCE_SEC = 300
def verify_webhook(raw_body: str, header: str, secret: str, now_sec: int) -> tuple[bool, str]:
t, v1 = None, []
for part in (header or "").split(","):
k, sep, v = part.partition("=")
if not sep or not k.strip():
continue
k, v = k.strip(), v.strip()
if k == "t":
t = v
elif k == "v1":
v1.append(v.lower())
if t is None or not re.fullmatch(r"\d+", t):
return False, "missing or malformed timestamp"
if not v1:
return False, "no v1 signature"
if abs(now_sec - int(t)) > TOLERANCE_SEC:
return False, "timestamp outside tolerance"
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
if any(hmac.compare_digest(sig, expected) for sig in v1):
return True, "ok"
return False, "signature mismatch"A parser stores header pairs in an object with sigs[k] = v, then checks sigs.v1. All tests pass. During the provider's secret rotation, some valid deliveries start failing. Why?
Replays, retries and idempotency
The timestamp window stops replays of old deliveries. It does not stop a replay inside the window, and it does not stop the sender itself delivering the same event twice. Senders retry any delivery that did not get a 2xx in time, and most guarantee "at least once", not "exactly once". So the handler must be idempotent:
- Verify, then dedupe on the event id. Record each processed id in a store with a time-to-live longer than the tolerance window (a day is common). If the id is already there, return
200and do nothing. - Acknowledge fast, work later. Put the verified event on a queue and return
200within a second or two. If your handler calls three APIs and renders a PDF before responding, a slow upstream turns into a sender timeout, a retry, and duplicate work. - Make the work itself idempotent. Keyed writes ("set invoice for order 1042") instead of appends ("add an invoice"). If an APIVerve call is part of the work, a duplicate costs credits but changes nothing, because APIVerve's POST endpoints are transformations rather than record creation. Dedupe anyway; credits are money.
// Any store with set-if-absent and expiry works: Redis SET NX EX, a database unique key, Workers KV.
export async function firstTime(store, eventId) {
return store.setIfAbsent(`webhook:${eventId}`, '1', { ttlSec: 86_400 }); // false when seen before
}For a scheme with no timestamp in the signature, like GitHub's, the delivery id header plus this dedupe store is your only replay defence, so it stops being optional.
Secrets, rotation and testing
A webhook secret is a credential. Store it in the same secrets manager as your API keys, one secret per endpoint per environment, and include it in your inventory. Rotation mirrors the sub-key overlap from lesson 1: during the overlap, accept a signature made with either secret, then retire the old one.
const secrets = [process.env.ORDERS_WEBHOOK_SECRET, process.env.ORDERS_WEBHOOK_SECRET_OLD].filter(Boolean);
const now = Math.floor(Date.now() / 1000);
const result = secrets.map((s) => verifyWebhook(rawBody, header, s, now)).find((r) => r.ok)
?? { ok: false, reason: 'signature mismatch' };Then test the verifier the way an attacker would probe it. Because verifyWebhook takes the clock as an argument, every case is deterministic, and the fixture is the delivery you signed in the try block above:
// node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verifyWebhook } from './verify-webhook.mjs';
const SECRET = 'whsec_test_123';
const BODY = '{"id":"evt_1","type":"order.paid"}';
const SIG = '559ce53eb4efe6d1069e5776295348f9902079cfaa716425b5f770682854f464';
const T = 1700000000;
const header = `t=${T},v1=${SIG}`;
test('accepts a valid delivery', () => assert.equal(verifyWebhook(BODY, header, SECRET, T + 10).ok, true));
test('rejects a tampered body', () => assert.equal(verifyWebhook(BODY.replace('paid', 'refunded'), header, SECRET, T).ok, false));
test('rejects the wrong secret', () => assert.equal(verifyWebhook(BODY, header, 'whsec_other', T).ok, false));
test('rejects a stale timestamp', () => assert.equal(verifyWebhook(BODY, header, SECRET, T + 301).reason, 'timestamp outside tolerance'));
test('rejects a future timestamp', () => assert.equal(verifyWebhook(BODY, header, SECRET, T - 301).ok, false));
test('accepts at the edge of tolerance', () => assert.equal(verifyWebhook(BODY, header, SECRET, T + 300).ok, true));
test('tries every v1', () => assert.equal(verifyWebhook(BODY, `t=${T},v1=${'0'.repeat(64)},v1=${SIG}`, SECRET, T).ok, true));
test('rejects a missing header', () => assert.equal(verifyWebhook(BODY, undefined, SECRET, T).ok, false));
test('rejects re-serialised JSON', () => assert.equal(verifyWebhook(JSON.stringify(JSON.parse(BODY), null, 2), header, SECRET, T).ok, false));The last case documents the raw-body rule as a test, so the next person who "simplifies" the handler to req.json() finds out from CI.
What you have now
A verifier that hashes the exact raw bytes, rejects anything outside a two-sided five-minute window, tries every signature during a rotation and compares in constant time; a handler that refuses before it parses, acknowledges fast and dedupes on event id; and a test suite that proves each of those properties. With credentials covered in both directions, the next job is seeing what your integration is actually doing in production: Monitor API integrations with logs and alerts.