Verify a Webhook Signature
MutoPay signs every webhook with HMAC-SHA256. This page shows how to verify the X-MutoPay-Signature header in Node.js, Python, and PHP — copy-paste, no dependencies.
Every MutoPay webhook includes an X-MutoPay-Signature header containing an HMAC-SHA256 digest of the raw request body. Verify it before trusting the payload.
The algorithm
signature = HMAC-SHA256(webhook_secret, raw_request_body)
expected = "sha256=" + hex(signature)
valid = constant_time_equal(expected, header_value)
Three things that matter:
- Use the raw request body bytes. Do not parse JSON first and re-serialize — whitespace and key order will change and the signature will fail.
- Use the webhook secret for this specific channel (each channel has its own secret). Find it at Settings → Channels.
- Use a constant-time comparison (
crypto.timingSafeEqual/hmac.compare_digest/hash_equals) — not===orstrcmp. Regular comparison leaks timing information and enables signature forgery.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.MUTOPAY_WEBHOOK_SECRET;
// IMPORTANT: use express.raw, not express.json — we need the raw bytes.
app.post(
'/webhooks/mutopay',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.header('X-MutoPay-Signature') ?? '';
const expected =
'sha256=' +
crypto.createHmac('sha256', WEBHOOK_SECRET).update(req.body).digest('hex');
const ok =
header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// handle event.event / event.payment_id / event.status ...
res.status(200).send('ok');
}
);
Node.js (Cloudflare Workers / Hono)
async function verify(secret: string, body: string, header: string | null) {
if (!header) return false;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
const expected =
'sha256=' +
Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// Constant-time compare
if (expected.length !== header.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ header.charCodeAt(i);
return diff === 0;
}
app.post('/webhooks/mutopay', async (c) => {
const body = await c.req.text();
const ok = await verify(
c.env.MUTOPAY_WEBHOOK_SECRET,
body,
c.req.header('X-MutoPay-Signature') ?? null
);
if (!ok) return c.text('invalid signature', 401);
const event = JSON.parse(body);
// handle event ...
return c.text('ok', 200);
});
Python (Flask)
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["MUTOPAY_WEBHOOK_SECRET"].encode()
@app.post("/webhooks/mutopay")
def mutopay_webhook():
body = request.get_data() # raw bytes
header = request.headers.get("X-MutoPay-Signature", "")
expected = "sha256=" + hmac.new(WEBHOOK_SECRET, body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header):
abort(401)
event = request.get_json()
# handle event["event"] / event["payment_id"] / event["status"] ...
return "", 200
PHP
<?php
$secret = getenv('MUTOPAY_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_MUTOPAY_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($expected, $header)) {
http_response_code(401);
exit('invalid signature');
}
$event = json_decode($body, true);
// handle $event['event'] / $event['payment_id'] / $event['status'] ...
http_response_code(200);
echo 'ok';
Testing
From Settings → Channels click Send test webhook. The signature is generated the same way as a real event — if your verification code accepts the test payload, it will accept production webhooks.
See also
- Webhooks — events, payloads, retry schedule.