# 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_connected` | A customer connects a financial account, including during a DirectPay payment |
| `mono.events.account_updated` | An account's data is refreshed or its data status changes |
| `mono.events.account_unlinked` | A connected account is unlinked from your app or business |
| `direct_debit.payment_successful` | A one-time debit completes |
| `direct_debit.payment_failed` | A bank failure or network switch downtime stops a debit after the customer authorised it |
| `direct_debit.payment_cancelled` | The customer closes the DirectPay widget without completing authorisation |
| `direct_debit.payment_abandoned` | The customer ignores the payment widget, or the payment link session times out |

Every body uses the same envelope. Here is Mono's `direct_debit.payment_failed` example:

```json
{
  "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](https://inspect.hookdeck.com) URL to see each event's headers and body, with no account or setup required.

## Setting up Mono webhooks

1. Log in to the Mono dashboard and open **Apps**.
2. Select your app, go to its **Webhooks** section and click **Add webhook**.
3. Enter your HTTPS endpoint URL and save.
4. 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.

```javascript
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:

```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](/event-gateway) checks the `mono-webhook-secret` header, answers Mono with a `200` straight 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 `200` once the event is stored.
* Return `401` only 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](/docs/retries) 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_id` before 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](/docs/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_debit.payment_successful` and so on. Field tables on other pages also show names such as `mono.events.account_connected.failed` 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.type` as well when you need to separate one-time debits from other payment events.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/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](/webhooks/guides/implement-webhook-idempotency).

### Verify payments before fulfilling

A `direct_debit.payment_successful` 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_connected` 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Mono webhooks reliably in minutes.