Mintcash
ConceptsWebhooks

Verify webhook signatures

Every webhook carries an HMAC-SHA256 signature in the x-signature header. Verify it before doing anything else.

Anyone can POST to your webhook URL. Without signature verification, a hostile actor can forge a payment.succeeded event and trick you into fulfilling an order. Every MintCash webhook is signed — verify before processing.

How the signature is computed

signature = HEX(HMAC-SHA256(rawRequestBody, webhookSigningSecret))
  • The signing secret is issued at merchant creation. It's shown once — store it in your secrets manager.
  • HMAC is computed over the raw request body bytes, before any JSON parsing.
  • The result is hex-encoded and placed in the x-signature header.

Reference implementation

import crypto from "node:crypto";

export function verifyWebhook(
  rawBody: string | Buffer,
  signatureHeader: unknown,
  secret: string,
): boolean {
  if (typeof signatureHeader !== "string") return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
import hmac
import hashlib

def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
package mintcash

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func VerifyWebhook(rawBody []byte, signatureHeader, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(signatureHeader))
}
require "openssl"

def verify_webhook(raw_body, signature_header, secret)
  expected = OpenSSL::HMAC.hexdigest(
    OpenSSL::Digest.new("sha256"),
    secret,
    raw_body
  )
  return false unless signature_header.is_a?(String)
  return false unless expected.bytesize == signature_header.bytesize
  OpenSSL::Util.secure_compare(expected, signature_header)
end

Three rules

Verify against the raw body — not the parsed JSON

JSON.parse then JSON.stringify is not a no-op. Whitespace, key order, and number formatting can all change. The HMAC is over the bytes we sent; re-serializing breaks the signature.

Use a constant-time comparison

Plain == leaks timing information about which prefix bytes matched. Always use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), hmac.Equal (Go), or your language's equivalent.

Reject on mismatch with 401

Don't return 200 when verification fails — that tells us the delivery succeeded and we'll stop retrying. Return 401, log the attempt, and consider alerting if the rate of failures spikes.

The trick most frameworks get wrong: by the time your handler runs, the body has been parsed into an object. You need the bytes as they arrived. Different frameworks expose this differently:

Next.js App Router

// Easy — req.text() gives you the raw body
export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("x-signature");
  // verify, then JSON.parse(body) once trusted
}

Express

import express from "express";
const app = express();

// Mount raw body parser for the webhook route only
app.use("/webhooks/mintcash", express.raw({ type: "application/json" }));

app.post("/webhooks/mintcash", (req, res) => {
  const rawBody = req.body; // Buffer
  const signature = req.headers["x-signature"];
  // verify, then JSON.parse(rawBody.toString("utf-8"))
});

Fastify

import fastify from "fastify";
const app = fastify();

app.addContentTypeParser(
  "application/json",
  { parseAs: "buffer" },
  (_req, body, done) => done(null, body),
);

app.post("/webhooks/mintcash", (req, reply) => {
  const rawBody = req.body as Buffer;
  // verify against rawBody, then JSON.parse(rawBody.toString("utf-8"))
});