Signed Webhooks with HMAC-SHA256: A Practical Guide for Fintech Engineers
Entrustt sends signed, replayable webhooks when a customer’s quiet-period state changes. This guide shows how to verify any HMAC-SHA256 signed webhook properly, with a working Node example.
Entrustt shows a three-step flow on its developers page: get sandbox keys, send a signal, receive a signed webhook. The steps below apply to any provider that signs with HMAC-SHA256.
How the signature works
Sender and receiver share a secret. For each event the sender builds a string from the timestamp and the exact body, computes an HMAC with SHA-256 using the secret, and sends the result in a header. The receiver does the same sum and compares. If the two match, the message came from someone holding the secret and arrived unchanged.
The example uses X-Signature and X-Timestamp and signs timestamp + "." + body. Treat these as an example and follow your provider’s documentation.
Verification in Node
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // use a database or cache with expiry in production
// Keep the raw body. The signature covers the exact bytes that were sent.
app.post("/webhooks/inactivity", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.header("X-Timestamp");
const signature = req.header("X-Signature");
if (!timestamp || !signature) return res.sendStatus(400);
// 1. Reject old or future timestamps.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return res.sendStatus(400);
// 2. Recompute the signature from the raw body.
const expected = crypto
.createHmac("sha256", SECRET)
.update(timestamp + "." + req.body.toString("utf8"))
.digest("hex");
// 3. Compare in constant time. Lengths must match first.
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
// 4. Process each event once.
const event = JSON.parse(req.body.toString("utf8"));
if (seen.has(event.id)) return res.sendStatus(200);
seen.add(event.id);
// 5. Acknowledge fast, work later.
queueForProcessing(event);
res.sendStatus(200);
});The checks, in order
- Use the raw body. Do not parse and re-serialise it first.
- Check the timestamp. A window of a few minutes limits how long a captured message stays useful.
- Compare in constant time with
timingSafeEqualor your language’s equivalent. - Reject replays. Keep processed event ids at least as long as the sender might retry.
- Return quickly. Store the event, return 2xx, process afterwards.
Retries and secrets
- Senders retry, so make your handler safe to run twice.
- Ask the provider how long it retries, how it backs off and whether you can replay an event on demand.
- Keep the secret in a secrets manager, with separate secrets for sandbox and production.
- Rotate by accepting the old and new secret for a short period.
- Alert on a spike in failed verifications.
Test before you go live
- A valid event is accepted.
- One changed character in the body is rejected.
- The same event sent twice is processed once.
- An old timestamp is rejected.
Related reading
- On Entrustt: the product modules, solutions for banks and fintechs and pricing
- Inactivity tracking API architecture, the system these webhooks come from
- Next-of-kin API providers compared
Common questions
What does an HMAC-SHA256 webhook signature prove?
That the sender knew a shared secret and that the message was not changed on the way. It does not hide the contents. Use HTTPS for that.
Why must I use the raw request body?
The signature covers the exact bytes the sender signed. If a framework parses the JSON and you re-serialise it, spacing and key order can change and the check fails.
What is a replay attack?
Someone who captured a valid webhook sends it again later. A signed timestamp that you check against a short window, together with an event id you have already processed, stops it.
What should my endpoint return?
A 2xx status as soon as you have verified and stored the event. Do the slow work afterwards. If you return an error, the sender will retry.