Guides
Verify a signature
The two reference verifiers the API ships — copy one into your project as-is; it is the same code the service signs with.
Every delivery carries a MolnPay-Signature header: t=<unix seconds>,v1=<hex>[,v1=<hex>]. The signed payload is t + . + the raw request body bytes, HMAC-SHA256 with your endpoint's whsec_… secret, hex-encoded. Two v1 values appear during a secret rotation; one match passes. Reject anything older than 300 seconds. Webhooks has the full contract.
Both files below are dependency-free and include recipes for getting the raw body out of Express, Fastify, Next.js, FastAPI, Flask and Django.
/**
* MolnPay-Signature verification — Node.js reference implementation.
*
* Self-contained: `node:crypto` only, no dependencies. Copy it into your
* project as-is. This is the same logic the sending service uses, so if your
* verification disagrees with this file, your verification is wrong.
*
* THE ONE RULE: `rawBody` must be the EXACT BYTES you received. Not an object,
* not `JSON.stringify(parsedBody)`. Re-serializing reorders keys and changes
* whitespace, and the HMAC will not match. Getting raw bytes is framework-
* specific; three recipes are at the bottom of this file.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
/** Reject deliveries whose timestamp is further away than this. */
export const SIGNATURE_TOLERANCE_SEC = 300;
/**
* @param rawBody the raw request body, exactly as received
* @param header the `MolnPay-Signature` header value
* @param secret your endpoint's `whsec_…` signing secret
*/
export function verifyMolnPaySignature(
rawBody: string | Buffer,
header: string | null | undefined,
secret: string,
opts: { toleranceSec?: number; nowSeconds?: number } = {},
): boolean {
if (!header || !secret) return false;
const toleranceSec = opts.toleranceSec ?? SIGNATURE_TOLERANCE_SEC;
const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000);
// Header form: `t=<unix seconds>,v1=<hex>[,v1=<hex>]`
// Two v1 values appear during a secret rotation — one per active secret — so
// you keep verifying through a rotation without a coordinated cutover.
let ts: number | null = null;
const candidates: string[] = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
if (eq < 1) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t" && ts === null) {
if (!/^\d{1,12}$/.test(value)) return false;
ts = Number(value);
} else if (key === "v1") {
if (!/^[0-9a-f]{64}$/.test(value)) continue;
candidates.push(value);
}
// Ignore unknown keys (a future v2 scheme) so this keeps working unchanged.
}
if (ts === null || candidates.length === 0) return false;
// The timestamp is INSIDE the MAC, so an attacker replaying a captured
// delivery cannot simply restamp it — changing `t` invalidates every v1.
if (Math.abs(now - ts) > toleranceSec) return false;
const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody;
const expected = createHmac("sha256", secret)
.update(`${ts}.`)
.update(body)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
// Check every candidate with no early exit, so response timing doesn't reveal
// which secret matched.
let ok = false;
for (const c of candidates) {
const got = Buffer.from(c, "hex");
if (got.length === expectedBuf.length && timingSafeEqual(got, expectedBuf)) ok = true;
}
return ok;
}
// ─────────────────────────────────────────────────────────────────────────────
// Getting the raw body in common frameworks
// ─────────────────────────────────────────────────────────────────────────────
//
// Express — express.json() DESTROYS the raw body, so capture it in `verify`:
//
// app.use(express.json({
// verify: (req, _res, buf) => { (req as any).rawBody = buf; },
// }));
// app.post("/webhooks/wallet", (req, res) => {
// if (!verifyMolnPaySignature((req as any).rawBody, req.get("MolnPay-Signature"), SECRET)) {
// return res.sendStatus(400);
// }
// res.sendStatus(200); // ack FIRST
// void handleAsync(req.body); // then process
// });
//
// Fastify — add a content-type parser that keeps the string:
//
// app.addContentTypeParser("application/json", { parseAs: "string" }, (req, body, done) => {
// (req as any).rawBody = body;
// try { done(null, body.length ? JSON.parse(body as string) : {}); }
// catch (e) { done(e as Error, undefined); }
// });
//
// Next.js App Router — `req.text()` gives you the raw body directly:
//
// export async function POST(req: Request) {
// const raw = await req.text();
// if (!verifyMolnPaySignature(raw, req.headers.get("MolnPay-Signature"), SECRET)) {
// return new Response("bad signature", { status: 400 });
// }
// const event = JSON.parse(raw);
// return new Response("ok");
// }
//
// ─────────────────────────────────────────────────────────────────────────────
// Handling the event
// ─────────────────────────────────────────────────────────────────────────────
//
// 1. Verify the signature BEFORE trusting any field in the body.
// 2. Dedupe on `event.id` — delivery is at-least-once, and a replay from the
// dashboard re-sends the SAME id.
// 3. Order on `event.sequence`, not on arrival order. Events can arrive out of
// order. Sequence is monotonic per tenant but may contain gaps — compare,
// never count.
// 4. Return 2xx within 10 seconds, then process asynchronously. A slow 200 is
// recorded as a FAILED delivery and will be retried.
// 5. Treat the REST API as the source of truth. If an event and a GET disagree,
// the GET is right.