Webhooks and emails
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.
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:
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
bodyobject 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 thehashcan differ too; each request'shashalways matches that request's own bytes. - Where you find it: in the request body at
header.hash, and in thehashHTTP header.
A webhook request looks like this:
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}}
The HTTP header names notification_ and hash contain underscores. Many proxies and load balancers (nginx by default) silently drop such headers. Always read header.hash, header. and header. 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:
- Read the raw request body as bytes, before any JSON parsing.
- Take the text after
"body":up to, but not including, the final}. - Compute HMAC-SHA256 of that text with your client secret, hex-encoded.
- Compare it with
header.hashusing a constant-time comparison (a comparison whose duration doesn't reveal how many characters matched). - Only then parse the JSON and process the event.
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"
});
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
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));
}
}
<?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);
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));
}
}
Every event in the event catalogue 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.) ran before your route, so the raw bytes are gone |
Read the raw body on the webhook route (express.raw, request.), 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.
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-Keyheader (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.