# Verifying signatures

Every webhook is signed with your **client secret**, so you can prove it came from Multigate and wasn't changed on the way. Check the signature before you act on any webhook.

This page explains the signature and gives the check in five languages. For a complete receiver that also stores, de-duplicates and processes events, see [Build your receiver](01-overview.md#build-your-receiver).

## How the signature is made

Multigate computes an HMAC-SHA256 (a keyed hash: only someone who knows the secret can produce it) over the webhook's `body` and sends it as `hash`, written as 64 lower-case hexadecimal characters:

```text
hash = hex( HMAC_SHA256( key = your client secret, message = compact JSON of the "body" object ) )
```

- **Key:** your client secret (the *secret key* under **Developer Tool › API Configuration**). Moving to API keys doesn't change it: you still need it here. The sandbox and production have different secrets: use the one for the environment the webhook comes from.
- **Message:** the `body` object serialised as compact JSON (no spaces or line breaks), with its keys in the order they were sent. The key order can differ between retries of one delivery, so the `hash` can differ too; each request's `hash` always matches that request's own bytes.
- **Where you find it:** in the request body at `header.hash`, and in the `hash` HTTP header.

A webhook request looks like this:

```http
POST /webhooks/multigate HTTP/1.1
Content-Type: application/json
Idempotency-Key: 9b2f61c4-3c1e-4d0a-9a55-0e7e3f1a2b6c
notification_type: transfer
notification: transfer_completed
hash: 5d41402abc4b2a76b9719d911017c592ae2d5b6f0c1e1d8b8e0f9a3c7e2b1a40

{"header":{"notification_type":"transfer","notification":"transfer_completed","hash":"5d41402abc4b2a76b9719d911017c592ae2d5b6f0c1e1d8b8e0f9a3c7e2b1a40"},"body":{"uniqueRequestId":"po_7f3a91","status":"completed","amount":25000}}
```

:::warning[Read the signature from the body, not the headers]
The HTTP header names `notification_type` and `hash` contain underscores. Many proxies and load balancers (nginx by default) silently drop such headers. Always read `header.hash`, `header.notification_type` and `header.notification` from the JSON body. Use the HTTP headers only as a fallback.
:::

## The safest way to verify: use the raw bytes

Re-serialising parsed JSON can change number formats, escaping or key order, and then a genuine webhook fails verification. The `body` object is the last field of the request, so you can take its exact bytes from the raw request instead:

1. Read the **raw** request body as bytes, before any JSON parsing.
2. Take the text after `"body":` up to, but not including, the final `}`.
3. Compute HMAC-SHA256 of that text with your client secret, hex-encoded.
4. Compare it with `header.hash` using a **constant-time** comparison (a comparison whose duration doesn't reveal how many characters matched).
5. Only then parse the JSON and process the event.

<Tabs>
<Tab title="Node.js">

```javascript
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const CLIENT_SECRET = process.env.MG_CLIENT_SECRET;

// Keep the raw bytes: verification must run on exactly what was sent.
app.post('/webhooks/multigate', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const marker = raw.indexOf('"body":');
  const signedPart = marker === -1 ? raw : raw.slice(marker + '"body":'.length, raw.lastIndexOf('}'));
  const event = JSON.parse(raw);
  const received = event.header?.hash ?? req.get('hash') ?? '';

  const expected = Buffer.from(crypto.createHmac('sha256', CLIENT_SECRET).update(signedPart, 'utf8').digest('hex'));
  const given = Buffer.from(String(received), 'utf8');
  // Compare lengths first: timingSafeEqual throws on buffers of different lengths.
  const valid = given.length === expected.length && crypto.timingSafeEqual(given, expected);
  if (!valid) return res.status(401).end();

  res.status(200).end(); // acknowledge fast, then process asynchronously
  queueForProcessing(req.get('Idempotency-Key'), event); // your code: see "Build your receiver"
});
```

</Tab>
<Tab title="Python">

```python
import hashlib
import hmac
import json
import os

from flask import Flask, request, abort

app = Flask(__name__)
CLIENT_SECRET = os.environ["MG_CLIENT_SECRET"].encode("utf-8")


@app.post("/webhooks/multigate")
def multigate_webhook():
    raw = request.get_data(as_text=True)  # raw text, not request.json
    marker = raw.find('"body":')
    signed_part = raw if marker == -1 else raw[marker + len('"body":'):raw.rfind("}")]
    event = json.loads(raw)
    received = (event.get("header") or {}).get("hash") or request.headers.get("hash", "")

    expected = hmac.new(CLIENT_SECRET, signed_part.encode("utf-8"), hashlib.sha256).hexdigest()
    # Compare bytes: compare_digest raises TypeError on non-ASCII str input.
    if not hmac.compare_digest(str(received).encode("utf-8"), expected.encode("utf-8")):
        abort(401)

    queue_for_processing(request.headers.get("Idempotency-Key"), event)  # your code: see "Build your receiver"
    return "", 200
```

</Tab>
<Tab title="Java">

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;

public final class MultigateSignature {
    private MultigateSignature() {}

    /** Returns true when {@code receivedHash} matches the signature of the raw webhook body. */
    public static boolean isValid(String rawBody, String receivedHash, String clientSecret) throws Exception {
        int marker = rawBody.indexOf("\"body\":");
        String signedPart = marker == -1
            ? rawBody
            : rawBody.substring(marker + "\"body\":".length(), rawBody.lastIndexOf('}'));

        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(clientSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String expected = HexFormat.of().formatHex(mac.doFinal(signedPart.getBytes(StandardCharsets.UTF_8)));

        return receivedHash != null && MessageDigest.isEqual(
            expected.getBytes(StandardCharsets.UTF_8), receivedHash.getBytes(StandardCharsets.UTF_8));
    }
}
```

</Tab>
<Tab title="PHP">

```php
<?php
function multigate_signature_is_valid(string $rawBody, string $receivedHash, string $clientSecret): bool
{
    $marker = strpos($rawBody, '"body":');
    $signedPart = $marker === false
        ? $rawBody
        : substr($rawBody, $marker + strlen('"body":'), strrpos($rawBody, '}') - $marker - strlen('"body":'));

    $expected = hash_hmac('sha256', $signedPart, $clientSecret);
    return hash_equals($expected, $receivedHash);
}

$raw = file_get_contents('php://input');          // raw body, before json_decode
$event = json_decode($raw, true);
$received = $event['header']['hash'] ?? ($_SERVER['HTTP_HASH'] ?? '');

if (!is_string($received) || !multigate_signature_is_valid($raw, $received, getenv('MG_CLIENT_SECRET'))) {
    http_response_code(401);
    exit;
}
http_response_code(200);
```

</Tab>
<Tab title="C#">

```csharp
using System;
using System.Security.Cryptography;
using System.Text;

public static class MultigateSignature
{
    /// <summary>True when receivedHash matches the signature of the raw webhook body.</summary>
    public static bool IsValid(string rawBody, string receivedHash, string clientSecret)
    {
        const string marker = "\"body\":";
        var start = rawBody.IndexOf(marker, StringComparison.Ordinal);
        var signedPart = start == -1
            ? rawBody
            : rawBody.Substring(start + marker.Length, rawBody.LastIndexOf('}') - start - marker.Length);

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(clientSecret));
        var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPart))).ToLowerInvariant();

        return receivedHash is not null && CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(receivedHash));
    }
}
```

</Tab>
</Tabs>

:::info[Requests without a "body" field]
Every event in the [event catalogue](02-event-catalogue.md) uses the `header`/`body` wrapper. A few product-specific events outside these docs are sent unwrapped: the request body is the event itself, signed as a whole, with the signature only in the `hash` HTTP header. The samples fall back to that when there is no `"body":` field.
:::

## Common mistakes

| Symptom | Cause | Fix |
|---|---|---|
| Every webhook fails verification | Signing the whole request instead of the `body` part, or using the client key instead of the client secret | Sign only the `body` part with the client secret. |
| Every webhook fails after you moved to API keys | Your API key is used as the signing key | Webhooks are signed with the client secret, never with an API key. Keep the client secret in your webhook handler. |
| Every webhook fails, and the body you read is already an object | A JSON body parser (for example `app.use(express.json())`) ran before your route, so the raw bytes are gone | Read the raw body on the webhook route (`express.raw`, `request.get_data()`), and register that route before any global JSON parser. |
| Some webhooks fail, most pass | Re-serialising parsed JSON changed numbers or escaping | Use the raw bytes as shown above. |
| Webhooks stopped verifying on a certain date | The client secret was rotated. The new secret signs every webhook from then on, including retries of events first sent before the rotation. | Update the secret in your webhook handler at the same time as your API credentials. |
| `hash` header missing | A proxy dropped headers containing underscores | Read `header.hash` from the body. |
| A retry has a different `hash` from the first attempt | The key order inside `body` changed between attempts. Both signatures are valid. | Verify each request on its own bytes. De-duplicate on `Idempotency-Key`, never on `hash` or raw bytes. |

To test your check without waiting for Multigate, [send yourself a signed test event](01-overview.md#test-it).

## Replay protection

The signature proves who sent a webhook, but it carries no timestamp, so a captured webhook could be sent to you again. Protect yourself by:

- de-duplicating on the `Idempotency-Key` header (the same across retries of one delivery) and on the object's ID and status; and
- treating webhooks as a trigger to act on the **current** state: for money-related events, check the object (for example the transfer status) through the API if the action is sensitive.
