MolnPayDocs
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.