Gareth Wilson Gareth Wilson

Guide to Mono Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationMono dashboard: Apps, your app, Webhooks, Add webhook
Subscription modelOne URL per app. Every event for the app is sent to it
Method and formatPOST, JSON body
AuthenticationThe app's webhook secret, as plain text, in mono-webhook-secret
Signature and timestampNone. The body has a timestamp field, but nothing is signed
Bodyevent, event_id, timestamp, data
Success response200. Other status codes are retried
RetriesExponential backoff from 30 seconds, doubling to a 4-hour cap, 25 attempts within 48 hours
Idempotency keyevent_id, reused on every retry of the same event
Source IPsNot 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.

EventFires when
mono.events.account_connectedA customer connects a financial account, including during a DirectPay payment
mono.events.account_updatedAn account's data is refreshed or its data status changes
mono.events.account_unlinkedA connected account is unlinked from your app or business
direct_debit.payment_successfulA one-time debit completes
direct_debit.payment_failedA bank failure or network switch downtime stops a debit after the customer authorised it
direct_debit.payment_cancelledThe customer closes the DirectPay widget without completing authorisation
direct_debit.payment_abandonedThe 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:

{
  "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

  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.

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-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 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 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 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_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.

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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.