Guide to Mono Webhooks: Features and Best Practices
Mono webhooks keep your application in step with your customers' bank accounts and payments. When a customer connects an account through Mono Connect, when Mono refreshes that account's data, or when a DirectPay payment succeeds, fails or is abandoned, Mono sends an event to your server, so you can update balances, release orders or follow up with the customer without polling the API.
This guide covers how Mono webhooks are configured and authenticated, the events Mono documents and the shape of their payloads, how Mono's retries work, and the practices that keep a Mono receiver reliable.
What are Mono webhooks?
Each app you create in the Mono dashboard has one webhook URL. Every event for that app goes to it as a JSON POST, whichever product it comes from. When you add the URL, Mono generates a webhook secret for the app.
Mono does not sign webhooks. Every request carries the webhook secret, as plain text, in the mono-webhook-secret header. Your endpoint authenticates the request by comparing that header with the secret it has stored. Mono's own example does exactly this: it rejects the request with a 401 when the header does not match.
Mono webhook features
| Feature | Details |
|---|---|
| Configuration | Mono dashboard: Apps, your app, Webhooks, Add webhook |
| Subscription model | One URL per app. Every event for the app is sent to it |
| Method and format | POST, JSON body |
| Authentication | The app's webhook secret, as plain text, in mono-webhook-secret |
| Signature and timestamp | None. The body has a timestamp field, but nothing is signed |
| Body | event, event_id, timestamp, data |
| Success response | 200. Other status codes are retried |
| Retries | Exponential backoff from 30 seconds, doubling to a 4-hour cap, 25 attempts within 48 hours |
| Idempotency key | event_id, reused on every retry of the same event |
| Source IPs | Not published |
Common events
Mono documents these events on its Financial Data and one-time DirectPay pages. Other payment products, such as recurring direct debit mandates, have webhook pages of their own, so treat this list as a starting point.
| Event | Fires when |
|---|---|
mono.events.account_ | A customer connects a financial account, including during a DirectPay payment |
mono.events.account_ | An account's data is refreshed or its data status changes |
mono.events.account_ | A connected account is unlinked from your app or business |
direct_ | A one-time debit completes |
direct_ | A bank failure or network switch downtime stops a debit after the customer authorised it |
direct_ | The customer closes the DirectPay widget without completing authorisation |
direct_ | The customer ignores the payment widget, or the payment link session times out |
Every body uses the same envelope. Here is Mono's direct_ example:
{
"event": "direct_debit.payment_failed",
"event_id": "PsmZW6jiY6vDuDHeFmvsiJudamnPHuKhAKyoMFPznWs",
"timestamp": "2026-01-07T10:10:50.186Z",
"data": {
"type": "onetime-debit",
"object": {
"description": "Wallet Funding",
"amount": 50000,
"fee": 4500,
"liveMode": true,
"customer": null,
"account": "618d41d61846d1e2",
"currency": "NGN",
"reference": "wp_f4719afff5234d60c0570e",
"status": "failed",
"id": "txd_XzhFBoknIjuosOz4",
"created_at": "2024-05-04T17:48:33.627Z",
"updated_at": "2024-05-04T17:49:06.211Z"
}
}
}
The shape of data depends on the event. DirectPay events put the transaction under data.object with data.type set to onetime-debit. account_updated puts the account under data.account and its data status under data.meta.data_status. account_connected carries the account id and customer directly under data.
Inspect Mono webhooks as they arrive. Point your app's webhook URL at a Hookdeck Inspect URL to see each event's headers and body, with no account or setup required.
Setting up Mono webhooks
- Log in to the Mono dashboard and open Apps.
- Select your app, go to its Webhooks section and click Add webhook.
- Enter your HTTPS endpoint URL and save.
- Copy the webhook secret that Mono generates for the app, and store it server-side, for example as
MONO_WEBHOOK_SECRET.
To route through Hookdeck, create a Hookdeck source with the Mono type and use its URL as the app's webhook URL. The source has one field, Webhook Secret: use the webhook secret from your app in the Mono dashboard. Hookdeck reads it from the mono-webhook-secret header and compares the whole value. The source accepts POST only, which is the method Mono uses.
Securing Mono webhooks
Read mono-webhook-secret, compare it with your stored secret in constant time, and reject the request if either is missing. Mono's own example uses !==, which leaks timing information. A constant-time comparison costs nothing extra.
const crypto = require('crypto');
const express = require('express');
const WEBHOOK_SECRET = process.env.MONO_WEBHOOK_SECRET;
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on unequal lengths, so check first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post('/webhooks/mono', express.raw({ type: '*/*' }), (req, res) => {
const received = req.get('mono-webhook-secret');
if (!WEBHOOK_SECRET || typeof received !== 'string' || !safeEqual(received, WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.sendStatus(400);
}
// Skip event.event_id if already processed, store the event, then route on event.event.
res.sendStatus(200); // exactly 200: Mono retries other status codes
});
The same check in Python:
import hmac
import os
WEBHOOK_SECRET = os.environ.get("MONO_WEBHOOK_SECRET", "")
def verify_mono_request(headers) -> bool:
if not WEBHOOK_SECRET:
return False # never accept when unconfigured
received = headers.get("mono-webhook-secret") # case-insensitive in Starlette and Flask
if not received:
return False
# Compare bytes: compare_digest raises TypeError on a non-ASCII str.
return hmac.compare_digest(received.encode("utf-8"), WEBHOOK_SECRET.encode("utf-8"))
The webhook secret is a static credential. It authenticates the sender, not the body, so a modified payload still passes, and nothing stops a captured request from being replayed. Serve the endpoint over HTTPS only, keep the secret out of logs, and for anything that moves money, confirm the transaction with Mono's API before acting on it.
Make Mono webhooks production-ready. Hookdeck Event Gateway checks the
mono-webhook-secretheader, answers Mono with a200straight away, and queues every account and payment event durably before your service sees it.
Mono webhook limitations and pain points
Anything but 200 is retried
The Problem: Your endpoint processes an event and returns 201, 202 or 204, and the same event keeps arriving.
Why It Happens: Mono's pages disagree. The Webhooks guide says a 2xx acknowledges receipt, and then that URLs "must respond with a status 200 OK or the request will be considered unsuccessful". The DirectPay page says any non-200 status code, including 4xx and 5xx, triggers a retry.
Workarounds:
- Return exactly
200once the event is stored. - Return
401only for a failed secret check, and expect Mono to keep retrying that request for up to 48 hours.
How Hookdeck Can Help: Hookdeck acknowledges each delivery with a 200 as soon as it arrives, so Mono's retries stop, and then delivers to your service on a retry schedule you control.
48 hours of retries means duplicates
The Problem: One payment produces several identical events, and your system credits or ships twice.
Why It Happens: Mono retries failed deliveries 25 times over 48 hours, with delays of 30 seconds, 1 minute, 2 minutes and so on up to 4 hours. Mono also says the same event can be sent more than once because of network retries or system redundancy.
Workarounds:
- Store
event_idbefore processing and skip any event you have already handled. Mono reuses it on every retry. - Make the side effects themselves idempotent, for example by keying fulfilment on the transaction
reference.
How Hookdeck Can Help: Hookdeck deduplication can drop repeated deliveries before they reach your service, based on fields you choose, such as event_id.
Event names are not consistent
The Problem: A handler that matches on the mono.events. prefix misses every DirectPay event.
Why It Happens: The Webhooks guide says every event type is prefixed with mono.events, but the DirectPay events are named direct_ and so on. Field tables on other pages also show names such as mono.events.account_ that do not appear in the event lists.
Workarounds:
- Match on the full event name, and log and acknowledge any name you do not recognise.
- Route on
data.typeas well when you need to separate one-time debits from other payment events.
How Hookdeck Can Help: Hookdeck filters can route on the full event value, so account events and payment events can go to different services.
No signature, no IP list
The Problem: Nothing in a request proves the body came from Mono unchanged.
Why It Happens: Mono authenticates with a static header and publishes no source IP range.
Workarounds:
- Use HTTPS, compare the header in constant time, and rotate the secret if it might have leaked.
- Treat payment events as notifications, and re-read the transaction from Mono's API before fulfilling an order.
How Hookdeck Can Help: Hookdeck's Mono source compares the header with the secret you enter. Hookdeck answers with a 200 either way, but requests that fail the check are recorded as failed verification and never reach your service.
Best practices
Deduplicate on event_id
Store each event_id with a unique constraint before you act on the event. See our guide to webhook idempotency.
Verify payments before fulfilling
A direct_ event tells you to check, not to ship. Confirm the payment status with Mono's API using the transaction reference before releasing goods or crediting a wallet.
Fetch account details after connection
mono.events.account_ carries the account ID, not the full account. Use the ID to fetch identity, balance or transactions from Mono's API when you need them.
Treat data as event-specific
Do not assume one shape for data. Switch on event first, then read the fields that event documents, with optional access throughout.
Acknowledge fast and process asynchronously
Verify the secret, store the event, return 200, and do the work afterwards. See why to process webhooks asynchronously.
Conclusion
Mono sends every event for an app to one URL as a JSON POST, with the app's webhook secret in the mono-webhook-secret header and no signature. Compare the header in constant time over HTTPS, return exactly 200, deduplicate on event_id because Mono retries for up to 48 hours, match on full event names, and confirm payments with the API before acting on them.
Hookdeck Event Gateway checks the secret header, acknowledges Mono immediately and keeps every event until your service has processed it, so an outage on your side does not turn into two days of retries.
Get started with Hookdeck for free and handle Mono webhooks reliably in minutes.