Webhooks and emails
Webhooks overview
A webhook is an HTTPS POST request that Multigate sends to a URL on your server when something happens on your account: a transfer completes or fails, money arrives, an account opens, a customer is approved, a conversion settles. Many operations finish after the API call returns, and webhooks are how you learn their final outcome.
This page takes you from nothing to a working, tested webhook endpoint.
Before you start
- Your client secret for the environment (not your API key). It signs every webhook. See Your credentials.
- Your API key (
MG_API_KEY) and your client ID (MG_CLIENT_ID), from the Quickstart. - The refresh client API credentials permission (
can_) on your API user, to register the URL. Without it, registration returnsrefresh_ client_ api_ credentials 404"Resource not found". - A database to store events. The sample below uses PostgreSQL.
- A public HTTPS URL. While you develop, a tunnel to your laptop works (see Test it).
How delivery works
sequenceDiagram
autonumber
participant MG as Multigate
participant You as Your webhook endpoint
MG->>You: POST event (signed, with Idempotency-Key)
alt You reply 2xx within 9 seconds
You-->>MG: 200 OK
Note over MG: Delivered. No more attempts.
else Error, non-2xx or no reply in 9 seconds
You--xMG: 500 or timeout
loop Up to 5 retries, 5 to 10 minutes apart
MG->>You: POST the same event (same Idempotency-Key)
end
Note over MG: After the 5th retry fails, delivery stops for good.
end
| Rule | Value |
|---|---|
| Method and format | POST, Content-Type: application/json |
| Success | Any 2xx status. The response body is ignored. |
| Timeout | 9 seconds, including connecting. Aim to reply within 5 seconds: store the event, reply, then do the work. |
| Retries | After a timeout, a connection error or any non-2xx status: up to 5 retries, each 5 to 10 minutes after the previous attempt. The last retry happens about 25 to 50 minutes after the first attempt; after that the event is not sent again. |
| TLS | Use HTTPS with a valid certificate. A self-signed or expired certificate makes every delivery fail. |
| Redirects | Don't redirect. A 301 or 302 turns the delivery into a GET without the event, and Multigate counts it as delivered if that GET returns 2xx. |
| Duplicates | Possible. Retries of one delivery carry the same Idempotency-Key. An event can also be sent again as a new delivery with a new key, so de-duplicate on the object and its status too. |
| Ordering | Not guaranteed. Events are sent in parallel, and a retry can arrive after a later event. |
| Signature | HMAC-SHA256 of the event body with your client secret. See Verifying signatures. |
| No URL registered | Events are dropped, not queued. Events that happen before you register a URL in an environment are never delivered. |
Set up your endpoint
Create a POST route on your server, for example https://api.. Start from the sample receiver below.
Register it with Add or update a client configuration (field webhookUrl), or ask your admin to set it in the dashboard under Developer Tool › API Configuration. One URL receives every event for your business and your customers. Register it before you create anything: events are not kept for a URL registered later.
curl -s -X POST "$MG_BASE_URL/client-service/api/v1/client-configurations" \
-H "mp-api-key: $MG_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"mode\":\"sandbox\",\"clientId\":\"$MG_CLIENT_ID\",\"webhookUrl\":\"https://api.example.com/webhooks/multigate\"}"
A success response contains "message": "Client configuration updated successfully" and your webhookUrl. Check what's registered at any time with Get client configurations.
Send the same mode and your webhookUrl every time you save a configuration in an environment: the last configuration saved sets the webhook URL.
Check the signature before you trust the content. Reject anything that fails with 401.
Store the event, return 200, then process it in the background, so you always reply within 9 seconds (aim for 5).
Send yourself a signed test event, then trigger real events in the sandbox. See Test it.
The sandbox and production are separate environments, each with its own webhook registration. Within one environment, your business has one webhook URL: the client configuration's mode field (live or sandbox) does not give you a second URL, and the most recently saved configuration wins. Register your sandbox URL in the sandbox and your production URL in production.
Sandbox webhooks run on shared test infrastructure and can arrive late, or only on a retry. Payout results in the sandbox can also take 15 minutes or more. Keep the status fallback you need in production anyway. See Sandbox vs production.
Build your receiver
The sample below is a complete receiver you can copy. For every request it:
- reads the raw body (the exact bytes sent), because the signature covers those bytes;
- verifies the signature, and returns
401if it doesn't match; - stores the event in a table whose primary key is the
Idempotency-Keyheader, so a retried delivery is stored only once (this is called de-duplicating); - returns
200straight away; - processes the event in the background, inside a database transaction;
- picks the handler by
header.(the object) andnotification_ type header.(what happened).notification
A sweep runs every minute and processes any event a crash or an error left unprocessed. The row lock (FOR UPDATE SKIP LOCKED) means you can run the receiver on several servers at once: each event is still processed once.
Create the table first:
-- One row per delivery. The primary key makes a retried delivery a no-op.
CREATE TABLE multigate_webhook_events (
idempotency_key text PRIMARY KEY, -- the Idempotency-Key header (not always a UUID)
notification_type text NOT NULL, -- header.notification_type, for example transfer
notification text NOT NULL, -- header.notification, for example transfer_completed
body jsonb NOT NULL, -- the verified event body
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz -- set once your business logic has run
);
CREATE INDEX multigate_webhook_events_unprocessed
ON multigate_webhook_events (received_at) WHERE processed_at IS NULL;
// webhook-receiver.mjs (Node.js 18+). Install: npm install express pg
// Run: MG_CLIENT_SECRET=... DATABASE_URL=postgres://... node webhook-receiver.mjs
import crypto from 'node:crypto';
import express from 'express';
import pg from 'pg';
const CLIENT_SECRET = process.env.MG_CLIENT_SECRET; // your client secret signs every webhook
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
pool.on('error', (error) => console.error('idle database connection lost', { error: error.message })); // don't crash
const app = express();
// Step 1: read the raw body. The signature covers the exact bytes Multigate sent.
app.post('/webhooks/multigate', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const raw = req.body.toString('utf8');
let event;
try {
event = JSON.parse(raw);
} catch {
return res.sendStatus(400);
}
// Step 2: verify the signature before trusting anything in the request.
const receivedHash = event.header?.hash ?? req.get('hash') ?? '';
if (!isSignatureValid(raw, receivedHash)) {
console.warn('webhook rejected: invalid signature');
return res.sendStatus(401);
}
const key = req.get('Idempotency-Key');
if (!key) return res.sendStatus(400);
// Every documented event has header and body. Anything else is stored and ignored.
const { notification_type: type = '', notification = '' } = event.header ?? {};
try {
// Step 3: store the event once. A retry of the same delivery hits the primary key and is skipped.
const { rowCount } = await pool.query(
`INSERT INTO multigate_webhook_events (idempotency_key, notification_type, notification, body)
VALUES ($1, $2, $3, $4) ON CONFLICT (idempotency_key) DO NOTHING`,
[key, type, notification, event.body ?? event],
);
console.info('webhook received', { key, type, notification, duplicate: rowCount === 0 });
// Step 4: acknowledge straight away (Multigate waits at most 9 seconds).
res.sendStatus(200);
// Step 5: process after replying.
if (rowCount === 1) setImmediate(() => processEvent(key));
} catch (error) {
console.error('webhook not stored; Multigate will retry', { key, error: error.message });
res.sendStatus(500);
}
});
function isSignatureValid(raw, receivedHash) {
// Sign exactly the text after "body": up to the final }.
const marker = raw.indexOf('"body":');
const signedPart = marker === -1 ? raw : raw.slice(marker + '"body":'.length, raw.lastIndexOf('}'));
const expected = Buffer.from(crypto.createHmac('sha256', CLIENT_SECRET).update(signedPart, 'utf8').digest('hex'));
const given = Buffer.from(String(receivedHash), 'utf8');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
async function processEvent(key) {
let db;
try {
db = await pool.connect();
await db.query('BEGIN');
// Lock the row so only one worker, on any of your servers, processes it.
const { rows } = await db.query(
`SELECT notification_type, notification, body FROM multigate_webhook_events
WHERE idempotency_key = $1 AND processed_at IS NULL FOR UPDATE SKIP LOCKED`,
[key],
);
if (rows.length === 1) {
const { notification_type: type, notification, body } = rows[0];
await handleEvent(db, type, notification, body);
await db.query('UPDATE multigate_webhook_events SET processed_at = now() WHERE idempotency_key = $1', [key]);
}
await db.query('COMMIT');
} catch (error) {
await db?.query('ROLLBACK').catch(() => {}); // the connection may already be gone
console.error('webhook processing failed; the sweep will retry it', { key, error: error.message });
} finally {
db?.release();
}
}
// Step 6: handle each event. Run your updates on `db` so they commit together with processed_at.
// Each handler must be safe to run twice: the same outcome can arrive again with a new Idempotency-Key.
async function handleEvent(db, type, notification, body) {
switch (`${type}/${notification}`) {
case 'transfer/transfer_completed':
case 'transfer/transfer_failed':
// Find your payout by uniqueRequestId. If it isn't final yet, set it to body.status.
console.info('payout result', { uniqueRequestId: body.uniqueRequestId, status: body.status, reason: body.responseMessage });
break;
case 'transfer/refund':
// Find your refund by uniqueRequestId and set it to body.status (completed or failed).
console.info('refund result', { uniqueRequestId: body.uniqueRequestId, status: body.status });
break;
case 'account/inflow':
// Credit once per transactionId. amount is a decimal string: never parse it as a float.
console.info('money received', { transactionId: body.transactionId, account: body.receivingAccountNumber ?? body.accountNumber, amount: body.amount, currency: body.currencyIso3Code });
break;
case 'account/account_opened':
// Save the account numbers in fundingDetails against accountId and clientId.
console.info('account opened', { accountId: body.accountId, clientId: body.clientId, accountNumbers: (body.fundingDetails ?? []).map((d) => d.accountNumber) });
break;
case 'client/subclient_approved':
// status "active" = approved, "rejected" = declined. Ignore approvalStatus.
console.info('customer reviewed', { customerId: body.entityId, status: body.status });
break;
case 'receivables/inflow':
// Record the payment once per transactionDetails.transactionId; update the invoice status.
console.info('invoice payment', { receivableId: body.receivableDetails?.entityId, status: body.receivableDetails?.status, transactionId: body.transactionDetails?.transactionId });
break;
case 'trade/trade_completed':
// Act only on status "completed", and confirm the target account was credited before releasing funds.
console.info('conversion', { quoteId: body.id, status: body.status });
break;
default:
console.warn('unknown event ignored', { type, notification });
}
}
// Safety net: every minute, process events a crash or an error left unprocessed.
setInterval(async () => {
try {
const { rows } = await pool.query(
`SELECT idempotency_key FROM multigate_webhook_events
WHERE processed_at IS NULL AND received_at < now() - interval '1 minute'
ORDER BY received_at LIMIT 100`,
);
for (const { idempotency_key: key } of rows) await processEvent(key);
} catch (error) {
console.error('webhook sweep failed', { error: error.message });
}
}, 60_000);
app.listen(3000, () => console.info('Listening on http://localhost:3000/webhooks/multigate'));
# webhook_receiver.py (Python 3.9+). Install: pip install flask "psycopg[binary,pool]"
# Run: MG_CLIENT_SECRET=... DATABASE_URL=postgresql://... flask --app webhook_receiver run --port 3000
import hashlib
import hmac
import json
import logging
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, request
from psycopg.types.json import Jsonb
from psycopg_pool import ConnectionPool
CLIENT_SECRET = os.environ["MG_CLIENT_SECRET"].encode("utf-8") # your client secret signs every webhook
pool = ConnectionPool(os.environ["DATABASE_URL"], open=True)
workers = ThreadPoolExecutor(max_workers=4)
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("multigate-webhooks")
@app.post("/webhooks/multigate")
def receive_webhook():
# Step 1: read the raw body. The signature covers the exact bytes Multigate sent.
raw = request.get_data(as_text=True)
try:
event = json.loads(raw)
except ValueError:
return "", 400
# Step 2: verify the signature before trusting anything in the request.
received_hash = (event.get("header") or {}).get("hash") or request.headers.get("hash", "")
if not signature_is_valid(raw, received_hash):
log.warning("webhook rejected: invalid signature")
return "", 401
key = request.headers.get("Idempotency-Key")
if not key:
return "", 400
# Every documented event has header and body. Anything else is stored and ignored.
header = event.get("header") or {}
notification_type, notification = header.get("notification_type", ""), header.get("notification", "")
# Step 3: store the event once. A retry of the same delivery hits the primary key and is skipped.
# If this raises, Flask answers 500 and Multigate retries.
with pool.connection() as conn:
stored = conn.execute(
"INSERT INTO multigate_webhook_events (idempotency_key, notification_type, notification, body) "
"VALUES (%s, %s, %s, %s) ON CONFLICT (idempotency_key) DO NOTHING",
(key, notification_type, notification, Jsonb(event.get("body", event))),
).rowcount == 1
log.info("webhook received key=%s event=%s/%s duplicate=%s", key, notification_type, notification, not stored)
# Steps 4 and 5: hand processing to a background thread, then acknowledge straight away
# (Multigate waits at most 9 seconds).
if stored:
workers.submit(process_event, key)
return "", 200
def signature_is_valid(raw, received_hash):
# Sign exactly the text after "body": up to the final }.
marker = raw.find('"body":')
signed_part = raw if marker == -1 else raw[marker + len('"body":'):raw.rfind("}")]
expected = hmac.new(CLIENT_SECRET, signed_part.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(str(received_hash).encode("utf-8"), expected.encode("utf-8"))
def process_event(key):
try:
with pool.connection() as conn, conn.transaction():
# Lock the row so only one worker, on any of your servers, processes it.
row = conn.execute(
"SELECT notification_type, notification, body FROM multigate_webhook_events "
"WHERE idempotency_key = %s AND processed_at IS NULL FOR UPDATE SKIP LOCKED",
(key,),
).fetchone()
if row is None:
return
handle_event(conn, *row)
conn.execute("UPDATE multigate_webhook_events SET processed_at = now() WHERE idempotency_key = %s", (key,))
except Exception:
log.exception("webhook processing failed key=%s; the sweep will retry it", key)
# Step 6: handle each event. Run your updates on `conn` so they commit together with processed_at.
# Each handler must be safe to run twice: the same outcome can arrive again with a new Idempotency-Key.
def handle_event(conn, notification_type, notification, body):
event = f"{notification_type}/{notification}"
if event in ("transfer/transfer_completed", "transfer/transfer_failed"):
# Find your payout by uniqueRequestId. If it isn't final yet, set it to body["status"].
log.info("payout result %s %s %s", body["uniqueRequestId"], body["status"], body.get("responseMessage"))
elif event == "transfer/refund":
# Find your refund by uniqueRequestId and set it to body["status"] (completed or failed).
log.info("refund result %s %s", body["uniqueRequestId"], body["status"])
elif event == "account/inflow":
# Credit once per transactionId. amount is a decimal string: use Decimal, never float.
account = body.get("receivingAccountNumber") or body.get("accountNumber")
log.info("money received %s %s %s %s", body["transactionId"], account, body["amount"], body["currencyIso3Code"])
elif event == "account/account_opened":
# Save the account numbers in fundingDetails against accountId and clientId.
numbers = [d.get("accountNumber") for d in body.get("fundingDetails") or []]
log.info("account opened %s %s %s", body["accountId"], body["clientId"], numbers)
elif event == "client/subclient_approved":
# status "active" = approved, "rejected" = declined. Ignore approvalStatus.
log.info("customer reviewed %s %s", body["entityId"], body["status"])
elif event == "receivables/inflow":
# Record the payment once per transactionDetails.transactionId; update the invoice status.
log.info("invoice payment %s %s %s", body["receivableDetails"]["entityId"],
body["receivableDetails"]["status"], body["transactionDetails"]["transactionId"])
elif event == "trade/trade_completed":
# Act only on status "completed", and confirm the target account was credited before releasing funds.
log.info("conversion %s %s", body["id"], body["status"])
else:
log.warning("unknown event ignored: %s", event)
def sweep_forever():
# Safety net: every minute, process events a crash or an error left unprocessed.
while True:
time.sleep(60)
try:
with pool.connection() as conn:
keys = [r[0] for r in conn.execute(
"SELECT idempotency_key FROM multigate_webhook_events "
"WHERE processed_at IS NULL AND received_at < now() - interval '1 minute' "
"ORDER BY received_at LIMIT 100").fetchall()]
for key in keys:
process_event(key)
except Exception:
log.exception("webhook sweep failed")
threading.Thread(target=sweep_forever, daemon=True).start()
Then make it yours:
- Replace the log lines in the event handler with your own updates, and run them on the transaction it receives (
dborconn), so your update andprocessed_atare saved together. The comments say what each event needs; the event catalogue has the details. - Make each handler safe to run twice. The same outcome can arrive again as a new delivery, so check your own state first: for example, don't credit an inflow whose
transactionIdyou've already recorded. - Alert on stuck events: rows with
processed_atstill empty after 15 minutes mean a handler keeps failing. - Store the secret in a secrets manager, never in code.
Test it
With the receiver running on port 3000, sign and send a fake transfer_ event. This needs no Multigate account activity, and uses the same signing method as Multigate.
KEY="local-test-$(date +%s)" # a new Idempotency-Key; keep it to test a retry
BODY='{"uniqueRequestId":"po_test_0001","transactionId":"PMT-TEST-0001","status":"completed","amount":25000,"charge":50,"totalAmount":25050}'
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$MG_CLIENT_SECRET" | awk '{print $NF}')
curl -i -X POST "http://localhost:3000/webhooks/multigate" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "hash: $HASH" \
-d "{\"header\":{\"notification_type\":\"transfer\",\"notification\":\"transfer_completed\",\"hash\":\"$HASH\"},\"body\":$BODY}"
Check the results:
| Try | Expected |
|---|---|
| Run the command | 200, and a "webhook received" log with duplicate: false, then "payout result" |
Run only the curl command again, so KEY stays the same |
200 with duplicate: true, and no second "payout result" |
Change a character in BODY after computing HASH |
401 |
| Use a different secret | 401 |
Multigate can't reach localhost. Use a tunnelling tool, such as ngrok (ngrok http 3000) or Cloudflare Tunnel (cloudflared tunnel --url http://localhost:3000). It prints a public HTTPS address that forwards to your laptop.
Register https://<your-tunnel-address>/webhooks/multigate as your sandbox webhook URL with Add or update a client configuration.
- Your business has one sandbox URL, so this replaces any URL your team already uses in the sandbox. Agree on it with your team, and set it back when you're done.
- Many free tunnels give you a new address each time they start. Register the new address each time.
- While your laptop is off, events are retried for about 50 minutes, then dropped.
| Event | How to trigger it in the sandbox |
|---|---|
account / inflow |
Simulate an inflow into one of your account numbers. Arrives within a few minutes. |
transfer / transfer_ or transfer_ |
Create a transfer from a funded account. Can take 15 minutes or more, and the test bank decides the outcome. |
transfer / refund |
Refund an inflow, for example a simulated NGN inflow. See Refunds for the rules. |
account / account_ |
Create an account. |
client / subclient_ |
Submit a customer for review. Sandbox reviews can take days: ask your Multigate contact to complete the review. |
receivables / inflow |
Create a receivable, then simulate an inflow into its account number. |
trade / trade_ |
Lock a rate on a quote. |
What to log
| Log | Why |
|---|---|
Idempotency-Key, notification_, notification |
Find one delivery and its retries. |
The object's ID and status (unique, transactionId, accountId, entityId or id) |
Trace an event to your own records. |
| Whether the signature check passed, and the status you returned | Explain failed deliveries. |
| Whether it was a duplicate, and whether processing succeeded | Spot handlers that keep failing. |
Don't log your client secret or full event bodies in production: bodies contain account numbers and names. Logging the raw body is fine in the sandbox while you build.
The request
POST /webhooks/multigate HTTP/1.1
Content-Type: application/json
Idempotency-Key: 9b2f61c4-3c1e-4d0a-9a55-0e7e3f1a2b6c
notification_type: transfer
notification: transfer_completed
hash: <signature>
{
"header": {
"notification_type": "transfer",
"notification": "transfer_completed",
"hash": "<signature>"
},
"body": {
"uniqueRequestId": "po_7f3a91",
"transactionId": "PMT-261010-093000482913",
"status": "completed"
}
}
| Field | Meaning |
|---|---|
Idempotency-Key (HTTP header) |
Identical on every retry of one delivery. An opaque text value: often a UUID, but not always, so store it as text. Use it, together with the object ID and status, to ignore duplicates. |
header. |
The object the event is about: transfer, account, client, receivables or trade. |
header. |
What happened, for example transfer_ or inflow. |
header.hash |
The signature of body. It can differ between retries of one delivery (the key order inside body can change), so never use it to de-duplicate. |
body |
The event payload. Its shape depends on the event; see the event catalogue. |
The HTTP headers notification_, notification and hash repeat the values in header. Prefer the body values: some proxies drop header names that contain underscores.
Handle events safely
De-duplicate twice: by key and by state
Store each Idempotency-Key you have processed, with a unique constraint. When a key arrives again, return 200 and do nothing else.
The same event can also arrive as a separate delivery with a different key (for example, when an event is re-sent). So before acting, also check whether you've already applied this outcome to this object: for a transfer, compare unique and status with what you have stored.
Don't assume order
Events can arrive in any order. Never move an object out of a final state: once a transfer is completed or failed in your system, ignore later events for it.
Make final states final
completed and failed are final for a transfer. Update your records once, then notify your users.
Have a fallback
If you expect a webhook and it hasn't arrived (for example, a transfer still non-final 30 minutes after you created it), fetch the object through the API. Webhooks are retried for up to about 50 minutes; after that, polling is your safety net.
Some outcomes send no webhook at all, so polling is required for them. See Outcomes without a webhook.
Next
Every event, when it fires, what to do with it, and its payload.
How the signature works, with code in Node.js, Python, Java, PHP and C#.