# Verify a signature (/guides/verify-signature)



{/* generated from docs/wallet-api/examples/ — edit THOSE files */}

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](/guides/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.

<Tabs groupId="verifier-lang" items="[&#x22;TypeScript&#x22;, &#x22;Python&#x22;]">
  <Tab value="TypeScript">
    ```ts
    /**
     * 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.
    ```
  </Tab>

  <Tab value="Python">
    ```python
    """
    MolnPay-Signature verification — Python reference implementation.

    Standard library only. Copy it into your project as-is.

    THE ONE RULE: `raw_body` must be the EXACT BYTES you received. Not a dict, not
    `json.dumps(parsed)`. Re-serializing reorders keys and changes whitespace, and
    the HMAC will not match. Framework recipes are at the bottom.
    """

    from __future__ import annotations

    import hashlib
    import hmac
    import re
    import time

    # Reject deliveries whose timestamp is further away than this.
    SIGNATURE_TOLERANCE_SEC = 300

    _TS_RE = re.compile(r"^\d{1,12}$")
    _HEX_RE = re.compile(r"^[0-9a-f]{64}$")


    def verify_molnpay_signature(
        raw_body: bytes | str,
        header: str | None,
        secret: str,
        tolerance_sec: int = SIGNATURE_TOLERANCE_SEC,
        now_seconds: int | None = None,
    ) -> bool:
        """
        :param raw_body: the raw request body, exactly as received
        :param header:   the `MolnPay-Signature` header value
        :param secret:   your endpoint's `whsec_...` signing secret
        """
        if not header or not secret:
            return False

        now = int(time.time()) if now_seconds is None else now_seconds

        # 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.
        ts: int | None = None
        candidates: list[str] = []

        for part in header.split(","):
            key, sep, value = part.partition("=")
            if not sep:
                continue
            key, value = key.strip(), value.strip()

            if key == "t" and ts is None:
                if not _TS_RE.match(value):
                    return False
                ts = int(value)
            elif key == "v1":
                if _HEX_RE.match(value):
                    candidates.append(value)
            # Ignore unknown keys (a future v2 scheme) so this keeps working.

        if ts is None or not candidates:
            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 abs(now - ts) > tolerance_sec:
            return False

        body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
        expected = hmac.new(
            secret.encode("utf-8"),
            f"{ts}.".encode("utf-8") + body,
            hashlib.sha256,
        ).hexdigest()

        # compare_digest against every candidate, with no early exit, so response
        # timing doesn't reveal which secret matched.
        ok = False
        for c in candidates:
            if hmac.compare_digest(c, expected):
                ok = True
        return ok


    # ─────────────────────────────────────────────────────────────────────────────
    # Getting the raw body in common frameworks
    # ─────────────────────────────────────────────────────────────────────────────
    #
    # FastAPI — `await request.body()` returns the raw bytes:
    #
    #   @app.post("/webhooks/wallet")
    #   async def wallet_webhook(request: Request):
    #       raw = await request.body()
    #       if not verify_molnpay_signature(raw, request.headers.get("molnpay-signature"), SECRET):
    #           raise HTTPException(status_code=400, detail="bad signature")
    #       event = json.loads(raw)
    #       background_tasks.add_task(handle, event)   # ack first, process after
    #       return {"ok": True}
    #
    # Flask — use `request.get_data()`, NOT `request.json`:
    #
    #   @app.post("/webhooks/wallet")
    #   def wallet_webhook():
    #       raw = request.get_data()
    #       if not verify_molnpay_signature(raw, request.headers.get("MolnPay-Signature"), SECRET):
    #           return "", 400
    #       event = json.loads(raw)
    #       return "", 200
    #
    # Django — `request.body` is the raw bytes (do not touch `request.POST` first):
    #
    #   @csrf_exempt
    #   def wallet_webhook(request):
    #       if not verify_molnpay_signature(request.body, request.headers.get("MolnPay-Signature"), SECRET):
    #           return HttpResponse(status=400)
    #       return HttpResponse(status=200)
    #
    # ─────────────────────────────────────────────────────────────────────────────
    # 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 arrival order. It 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.
    ```
  </Tab>
</Tabs>
