# 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.

Source: https://mutopay.com/docs/verify-webhook-signature/
Language: en

---

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](https://mutopay.com/dashboard/settings).
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)

```js
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)

```ts
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)

```python
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
<?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](https://mutopay.com/dashboard/settings) 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](/docs/webhooks/): events, payloads, retry schedule.
