> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mattildapayments.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook signatures

Webhook signatures let you verify that a webhook notification was sent by mattilda Pay and that its
content was not modified on the way to your endpoint. Verifying the signature protects you from
malicious parties sending forged or tampered data to your webhook endpoint.

> [!NOTE]
>
> The payload is **signed**, not encrypted. You don't need to decrypt anything: you recompute the
> same signature with your secret and compare it with the one we sent.

## Configuration

Signatures are computed with a **signing secret** shared between mattilda Pay and your merchant
account. Secrets start with `whsec_`.

To get a signing secret, contact the mattilda Pay support team. They will generate the secret for
your merchant account and share it with you through a secure channel. Once the secret is active,
every webhook we deliver to your endpoint includes the `X-Signature` header.

> [!WARNING]
>
> Store the secret in a secure place, such as a secrets manager or an environment variable. Never
> commit it to source control or expose it in client-side code.

## The `X-Signature` header

Each signed webhook is an HTTP `POST` with a JSON body (`Content-Type: application/json`) and the
following header.

```http theme={"dark"}
X-Signature: t=1751500000,v1=d51e200fd63385842a96fdcd5c8edcaef9cde70799841b1113380df92eb07f6c
```

| Element | Description |
| :- | :- |
| `t` | UNIX timestamp, in seconds (UTC), of the moment the webhook was signed. It is **not** an expiration time. |
| `v1` | Hex-encoded (lowercase) HMAC-SHA256 signature. During a [secret rotation](#secret-rotation) the header contains more than one `v1` entry, one per active secret. |

The signature is computed as follows.

```
v1 = hex( HMAC_SHA256( key = secret, message = t + "." + raw_body ) )
```

Where `raw_body` is the exact body of the HTTP request, byte for byte.

> [!NOTE]
>
> Every delivery attempt is signed again, so a retried webhook carries a new `t` value and a
> new `v1` signature.

## Signature verification

To verify a webhook, follow these steps.

1. Read the **raw body** of the request, before any JSON parsing.
2. Parse the `X-Signature` header: split it by `,`, then split each element by the first `=`.
   Take the value of `t` and collect **all** the `v1` values in a list.
3. Build the signed content by joining the timestamp, a `.` character, and the raw body:
   `{t}.{raw_body}`.
4. Compute the HMAC-SHA256 of the signed content using your secret as the key, and encode it
   as lowercase hex.
5. Compare your value with each `v1` value using a constant-time comparison. The webhook is
   valid if **at least one** of them matches.
6. Check that `t` is within your tolerance window (we recommend 5 minutes) to protect against
   replay attacks.

> [!WARNING]
>
> Always verify the signature against the raw body you received. Don't parse the JSON and
> serialize it again: changes to whitespace, key order, or character escaping produce a
> different signature and the verification will fail.

### Code examples

```python Python theme={"dark"}
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_signature(secret: str, raw_body: bytes, header: str) -> bool:
    pairs = [item.split("=", 1) for item in header.strip().split(",")]
    timestamp = int(next(value for key, value in pairs if key == "t"))
    signatures = [value for key, value in pairs if key == "v1"]

    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False

    signed_content = f"{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(
        secret.encode("utf-8"), signed_content, hashlib.sha256
    ).hexdigest()

    return any(hmac.compare_digest(expected, sig) for sig in signatures)


# Flask example
# @app.post("/webhooks/mattilda")
# def webhook():
#     if not verify_signature(SECRET, request.get_data(), request.headers.get("X-Signature", "")):
#         abort(401)
#     event = request.get_json()
#     ...
#     return "", 200
```

```javascript Node.js theme={"dark"}
const crypto = require("crypto");
const express = require("express");

const TOLERANCE_SECONDS = 300;

function verifySignature(secret, rawBody, header) {
  const pairs = header.split(",").map((item) => {
    const index = item.indexOf("=");
    return [item.slice(0, index).trim(), item.slice(index + 1).trim()];
  });
  const timestamp = Number(pairs.find(([key]) => key === "t")?.[1]);
  const signatures = pairs.filter(([key]) => key === "v1").map(([, value]) => value);

  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");

  return signatures.some(
    (signature) =>
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
  );
}

const app = express();

// Use the raw body parser so the body is not re-serialized
app.post("/webhooks/mattilda", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySignature(process.env.MATTILDA_WEBHOOK_SECRET, req.body, req.get("X-Signature") || "")) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  // ...
  res.sendStatus(200);
});
```

```php PHP theme={"dark"}
<?php

const TOLERANCE_SECONDS = 300;

function verifySignature(string $secret, string $rawBody, string $header): bool
{
    $timestamp = null;
    $signatures = [];

    foreach (explode(',', $header) as $item) {
        [$key, $value] = array_map('trim', explode('=', $item, 2) + [1 => '']);
        if ($key === 't') {
            $timestamp = (int) $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }

    if ($timestamp === null || abs(time() - $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

if (!verifySignature(getenv('MATTILDA_WEBHOOK_SECRET'), $rawBody, $header)) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
// ...
http_response_code(200);
```

### Test values

Use these values to check your implementation. Disable the timestamp check (or set your clock
to `t`) while running this test.

| Input | Value |
| :- | :- |
| Secret | `whsec_selftest_secret` |
| Raw body | `{"name": "transaction.updated", "target": {"type": "transaction", "id": "abc"}}` |
| Header | `t=1751500000,v1=d51e200fd63385842a96fdcd5c8edcaef9cde70799841b1113380df92eb07f6c` |

The signature must match. Adding a single character to the body, or using a different secret,
must make the verification fail.

## Secret rotation

You can request a new signing secret from the mattilda Pay support team at any time, for example
if you suspect your secret was exposed.

During a rotation both the new and the previous secret are active for a grace period. While both
are active, every webhook is signed with each secret and the header contains one `v1` entry per
secret, the new one first:

```http theme={"dark"}
X-Signature: t=1751500000,v1=d51e200fd63385842a96fdcd5c8edcaef9cde70799841b1113380df92eb07f6c,v1=aa60557f8edaaf5dd86cf60cb84a4600ace124c9089e9d0e07b3ba97cab6d32f
```

Because your verification accepts the webhook when **any** `v1` matches, webhooks keep validating
with either secret. Update your endpoint to the new secret during the grace period, and let the
support team know so the previous secret can be retired.

> [!WARNING]
>
> Don't parse the header into a dictionary or map keyed by name. Repeated `v1` keys would
> overwrite each other and you would only keep one of the signatures.

## Best practices

* Respond with a `2XX` status code only after the signature is verified, and with a `4XX` status
  code when it isn't.
* Use a constant-time comparison function (`hmac.compare_digest`, `crypto.timingSafeEqual`,
  `hash_equals`) to compare signatures.
* Reject webhooks whose timestamp is outside your tolerance window.
* Process events idempotently: the same event can be delivered more than once, use the event
  `id` to detect duplicates.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.