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:

  1. 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.
  2. Use the webhook secret for this specific channel (each channel has its own secret). Find it at Settings → Channels.
  3. Use a constant-time comparison (crypto.timingSafeEqual / hmac.compare_digest / hash_equals) — not === or strcmp. 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.