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.

By Olalekan. Last checked 22 October 2026. General information, not legal advice.

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

  1. Use the raw body. Do not parse and re-serialise it first.
  2. Check the timestamp. A window of a few minutes limits how long a captured message stays useful.
  3. Compare in constant time with timingSafeEqual or your language’s equivalent.
  4. Reject replays. Keep processed event ids at least as long as the sender might retry.
  5. 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.

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.

Sources