Security
How to Verify Webhook Signatures in Node.js Without Breaking Retries
Secure shipment webhooks with HMAC verification, raw request bodies, timestamp tolerance, constant-time comparison, idempotency, and reliable retry handling.
A webhook endpoint is intentionally public. Authentication therefore travels with the request: the provider signs the exact payload, and your server independently calculates the signature with a shared secret. If the values match, the body was not altered and came from someone who knows the secret.
Most verification failures are implementation mistakes rather than cryptography mistakes. Parsing JSON before verification changes the bytes, ordinary string comparison can leak timing information, and accepting old timestamps makes captured requests replayable.
Verify the raw bytes, not parsed JSON
JSON objects do not have a canonical byte representation. Whitespace, property order, and escaping can change while the parsed object stays logically identical. HMAC verification must use the exact raw request body received from the network.
Configure the webhook route with a raw-body parser before any global JSON middleware. Parse the body only after verification succeeds.
app.post(
"/webhooks/trace",
express.raw({ type: "application/json" }),
handleTraceWebhook,
);
app.use(express.json());Reconstruct the signed message exactly
Trace signs the timestamp, a period, and the raw body with HMAC SHA-256. The Trace-Signature header carries values in the form t=<timestamp>,v1=<digest>. Extract both values, reject missing fields, and build the same signed message.
Keep the webhook signing secret separate from API keys and application session secrets. Rotate it independently when an endpoint or deployment is compromised.
import crypto from "node:crypto";
function expectedSignature(timestamp: string, rawBody: Buffer, secret: string) {
return crypto
.createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest("hex");
}Use constant-time comparison
A normal equality check can stop at the first different character. Over many carefully timed requests, that behaviour can reveal information about a valid signature. Convert both digests to equal-length buffers and compare them with timingSafeEqual.
Check length before calling the comparison function because unequal buffers throw. Treat any parse or length problem as a failed signature and return a 400 response.
function signaturesMatch(received: string, expected: string) {
const left = Buffer.from(received, "hex");
const right = Buffer.from(expected, "hex");
return left.length === right.length && crypto.timingSafeEqual(left, right);
}Reject stale timestamps
A valid signature proves authenticity, but without a timestamp window an attacker could replay a captured event indefinitely. Compare the signed timestamp with the current time and reject requests outside a short tolerance, commonly five minutes.
Allow enough tolerance for network delay and small clock differences. Keep server clocks synchronized and log rejections without storing the signing secret or full sensitive payload.
Make processing idempotent
Providers retry when your endpoint times out or returns a failure. That is correct behaviour. Your handler must safely accept the same event more than once by storing a stable event identifier or deriving a uniqueness key from the shipment, event type, status, and event timestamp.
Perform the idempotency insert and business update in one database transaction when possible. Sending two delivered emails is a product bug even when both webhook requests were legitimate.
Acknowledge quickly, work asynchronously
Signature verification and durable queue insertion belong in the request path. Email, analytics, inventory changes, and downstream calls do not. Return a 2xx response after the event is safely recorded, then let a worker process it.
This prevents slow dependencies from triggering retries and keeps your endpoint available during traffic bursts. If queue insertion fails, return an error so the provider retries rather than silently losing the event.
Test the security boundary
Your test suite should cover a valid signature, changed body, wrong secret, missing header, stale timestamp, malformed digest, and duplicate delivery. Also confirm that the raw-body parser applies only to the webhook route and does not break ordinary JSON endpoints.
Trace uses timestamped HMAC signatures so customers can build this boundary with standard platform libraries. Verification is small enough to test completely and important enough that it should never be copied in unreviewed from a random snippet.
Build tracking into your product
Create a Trace account, generate an API key, and test package tracking from the dashboard.