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-signatureheader.
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)
endThree 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.
Capturing the raw body in popular frameworks
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"))
});