# Guide to Modern Treasury Webhooks: Features and Best Practices

Modern Treasury webhooks tell your systems what happened to your money movements: a payment order was sent to the bank, an expected payment was reconciled, an incoming payment arrived, or a return came back. Much of what Modern Treasury does depends on banks that work in batches and files, so changes arrive later and asynchronously. Webhooks are how your ledger, product and operations tools hear about them without polling the API.

This guide covers how Modern Treasury webhooks are configured, signed and retried, how to work out which event you received, the events you are most likely to need, and the best practices for running a Modern Treasury receiver in production.

## What are Modern Treasury webhooks?

Modern Treasury webhook endpoints are configured for the whole organisation in Developer settings. You can give live and test traffic separate URLs, and choose which events each endpoint receives. When a matching event occurs, Modern Treasury sends an HTTPS `POST` with a JSON body.

Every body has the same two keys. `event` says what happened, and `data` is the full, updated object. The `event` value is only the action, such as `created` or `reconciled`. Which kind of object it happened to is in the `X-Topic` header, so the full event name is the topic and the action together: `X-Topic: expected_payment` plus `"event": "reconciled"` is an `expected_payment.reconciled` event.

Every request is signed. `X-Signature` holds a hex HMAC-SHA256 of the raw body, keyed with your organisation's webhook key.

## Modern Treasury webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Developer settings > Webhooks**, for the whole organisation |
| Live and test | Separate URLs, or one URL and the `X-Live-Mode` header |
| Body | `{ "event": <action>, "data": <object> }` |
| Event name | `X-Topic` header plus the body's `event` |
| Signature header | `X-Signature` |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The raw request body, nothing else |
| Secret | The webhook key in Developer settings, one per organisation. Rotation is through support |
| Idempotency key | `X-Webhook-ID`, stable across retries |
| Replay window | None. `X-Event-Time` is sent but not signed |
| Response window | 5 seconds, for a `2xx` |
| Retries | Exponential backoff, up to 15 attempts over roughly a day and a half |
| Exhausted retries | The endpoint is paused |
| Source IPs | Published on Modern Treasury's IP addresses page |
| Transport | HTTPS only, TLS 1.2 or later |

Other headers you will see: `X-Event-ID`, the ID of the Event object; `X-Delivery-ID`, which changes on every attempt; `X-Organization-ID`; and `X-Internal-Signature`, which Modern Treasury uses for its own checks and which you should ignore.

## Common events

Modern Treasury documents 16 topics. The ones most integrations start with are below, with a selection of their actions.

| `X-Topic` | Actions (`event`) | What they tell you |
| --- | --- | --- |
| `payment_order` | `created`, `approved`, `begin_processing`, `finish_processing`, `acknowledged`, `confirmed`, `completed`, `reconciled`, `failed`, `returned`, `cancelled`, `held`, `resolved` and more | Where an outgoing payment is in its life, from approval to the bank and back |
| `expected_payment` | `created`, `reconciled`, `partially_reconciled`, `tentatively_reconciled`, `overdue`, `unreconciled`, `archived` | Whether money you expect to receive has matched a transaction |
| `incoming_payment_detail` | `created`, `completed`, `reconciled`, `tentatively_reconciled`, `failed`, `returned`, `return_failed`, `unreconciled` | An incoming payment's progress |
| `transaction` | `created`, `updated`, `reconciled` | Bank transactions appearing and being matched |
| `return` | `created`, `begin_processing`, `finish_processing`, `completed`, `reconciled`, `failed`, `returned` and more | Returns of payments |
| `reversal` | `created`, `begin_processing`, `finish_processing`, `completed`, `failed`, `returned`, `unreconciled` | Payment reversals |

The other topics are `balance_report`, `case`, `connection_legal_entity`, `decision`, `external_account`, `internal_account`, `ledger_account_settlement`, `ledger_transaction`, `legal_entity` and `user_onboarding`.

Some actions carry more meaning than their names suggest. `payment_order.updated` fires when an attribute other than `status` changes. `payment_order.returned` can be followed by a redraft. `expected_payment.overdue` is sent at 6am Pacific Time once `date_upper_bound` has passed without a match.

Here is a `transaction.reconciled` delivery, from Modern Treasury's documentation and trimmed:

```
X-Topic: transaction
Content-Type: application/json

```

```json
{
  "event": "reconciled",
  "data": {
    "id": "30dd4826-732f-4fe5-ab15-fc903f85ffdd",
    "object": "transaction",
    "amount": 400000,
    "currency": "USD",
    "direction": "credit",
    "type": "ach",
    "posted": true,
    "reconciled": true,
    "as_of_date": "2019-12-12",
    "internal_account_id": "b9fc1ae0-d493-4f01-a7b3-b39104e802b5",
    "vendor_description": "ACH PAYMENT",
    "created_at": "2019-12-12T23:01:17Z",
    "updated_at": "2019-12-12T23:01:32Z"
  }
}

```

Amounts are integers in the currency's smallest unit. `data.object` repeats the topic in the documented examples, which gives you a fallback if a proxy drops the header.

## Setting up Modern Treasury webhooks

1. In the Modern Treasury app, open **Developer settings** and go to the webhooks page.
2. Add an endpoint with your HTTPS URL. Give test traffic its own URL if you want to keep it apart.
3. Choose the events the endpoint should receive. Modern Treasury recommends subscribing only to the events you need.
4. Copy the webhook key from the same settings and store it server-side, for example as `MODERN_TREASURY_WEBHOOK_KEY`.
5. Under **Settings > Notifications**, select the roles to notify for **Webhook Endpoints Fail**, so someone hears about failing or paused endpoints.

In sandbox, delivery is rate limited to 300 deliveries per minute across all endpoints, so large test runs can arrive with a delay.

## Securing Modern Treasury webhooks

Read `X-Signature`, compute a hex HMAC-SHA256 of the raw request body with your webhook key, and compare the two in constant time. Modern Treasury's own instructions are not to parse or change the body before you verify it.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyModernTreasurySignature(rawBody, signatureHeader, webhookKey) {
  if (!signatureHeader || !webhookKey) return false;
  const expected = crypto.createHmac('sha256', webhookKey).update(rawBody).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/modern-treasury', express.raw({ type: '*/*' }), (req, res) => {
  const webhookKey = process.env.MODERN_TREASURY_WEBHOOK_KEY;
  if (!webhookKey) return res.sendStatus(500); // fail closed

  // Node lowercases header names: use req.get() or req.headers['x-signature'].
  if (!verifyModernTreasurySignature(req.body, req.get('x-signature'), webhookKey)) {
    return res.sendStatus(401);
  }

  const { event, data } = JSON.parse(req.body.toString('utf8'));
  const topic = req.get('x-topic') ?? data?.object;
  const event_name = `${topic}.${event}`; // e.g. payment_order.completed
  const webhook_id = req.get('x-webhook-id'); // dedupe on this
  // Persist, then process asynchronously. Modern Treasury waits only 5 seconds.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_modern_treasury_signature(raw_body: bytes, signature_header, webhook_key: str) -> bool:
    if not signature_header or not webhook_key:
        return False
    expected = hmac.new(webhook_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_modern_treasury_signature(raw_body, request.headers.get("x-signature"),
#                                  os.environ["MODERN_TREASURY_WEBHOOK_KEY"])

```

To check your verifier, use the key `mt-test-webhook-key` and this exact body:

```
{"event":"reconciled","data":{"id":"30dd4826-732f-4fe5-ab15-fc903f85ffdd","object":"transaction"}}

```

The signature should be:

```
a948dfc6afe4da84789a87fa6c83781574d089315c55534b3b9937f3aaa83590

```

Modern Treasury also publishes the IP addresses it sends webhooks from, if you want to filter on them as well. If your webhook key is exposed, contact Modern Treasury support, who rotate the key and coordinate the change with you.

> **Make Modern Treasury webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Signature` header, answers inside Modern Treasury's 5-second window, and durably queues every payment event so your endpoint is never paused by your own downtime.

## Modern Treasury webhook limitations and pain points

### A 5-second window

**The Problem:** Deliveries your service handled correctly are still recorded as failures and retried.

**Why It Happens:** Modern Treasury waits at most 5 seconds for a `2xx`. After that, it closes the connection and records the attempt as failed, even if your handler finishes later. Writing to a ledger, calling another API or waiting on a lock can easily take longer.

**Workarounds:**

* Do only two things synchronously: verify the request and store it. This is what Modern Treasury recommends.
* Process stored events from a queue or background job.
* Make processing idempotent, because slow responses produce retries.

**How Hookdeck Can Help:** Hookdeck acknowledges each delivery at its edge, well inside the window, and then delivers it to your service with its own timeout and retries.

### Exhausted retries pause the endpoint

**The Problem:** After a long outage, the endpoint stops receiving events for every topic.

**Why It Happens:** Modern Treasury retries a failed delivery with increasing backoff. Once one event has used all 15 attempts, roughly a day and a half after the first, Modern Treasury pauses the endpoint by policy.

**Workarounds:**

* Subscribe the right roles to **Webhook Endpoints Fail** notifications, which also cover pauses.
* Review delivery attempts in Developer settings after any incident.
* Keep a reconciliation job that reads recent objects from the API.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, so your outage does not exhaust Modern Treasury's retries. You control the retry schedule to your service and can replay any event.

### The event name is split across header and body

**The Problem:** A handler that switches on `event` treats a payment order's `completed` and a return's `completed` as the same thing.

**Why It Happens:** `event` holds only the action, and most actions, such as `created`, are shared by many topics. The object type is in the `X-Topic` header, which is not covered by the signature. Modern Treasury's own Express sample also reads `req.headers['X-Signature']`, which is always `undefined` in Node, because Node lowercases header names.

**Workarounds:**

* Build a routing key from `X-Topic` and `event`, and fall back to `data.object` if the header is missing.
* Read headers in lowercase, or with a case-insensitive helper such as `req.get()`.
* Check that `data.object` agrees with `X-Topic` before acting, since only the body is signed.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on both headers and body, so you can route `X-Topic: payment_order` with `event: completed` to one destination and everything else to another.

### Some topics are not in the documented list

**The Problem:** Events arrive under an `X-Topic` value your router has never seen.

**Why It Happens:** Modern Treasury documents events for objects such as invoices, documents, virtual accounts and ledger accounts on their own pages, but those objects are not in the topics table, so the `X-Topic` value they arrive with is not documented.

**Workarounds:**

* Log and acknowledge unknown topics instead of rejecting them.
* Subscribe only to the events you handle, as Modern Treasury recommends, so unexpected topics are rare.

**How Hookdeck Can Help:** Every request is stored in Hookdeck, so you can inspect the real `X-Topic` value of a new event type and replay it once your handler supports it.

## Best practices

### Verify against the raw body

Mount `express.raw()` on the webhook route and parse only after verification. In FastAPI, read `await request.body()`. Re-serialised JSON will not match the signature.

### Deduplicate on `X-Webhook-ID`

Modern Treasury guarantees at-least-once delivery and documents `X-Webhook-ID` as the key to deduplicate on: it is the same across retries, while `X-Delivery-ID` changes on every attempt. Record it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Keep live and test traffic apart

Use separate URLs for live and test events, or branch on `X-Live-Mode`, so sandbox activity never touches production records.

### Subscribe only to what you need

Fewer event types mean fewer requests, lower failure rates and no surprises when Modern Treasury adds new events. For a full data sync, Modern Treasury suggests its API or Push to Warehouse product instead.

### Acknowledge fast and process asynchronously

Verify, store, return `2xx`, and process afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Modern Treasury signs every webhook with a hex HMAC-SHA256 of the raw body in `X-Signature`, keyed with your organisation's webhook key. Verify before parsing, build the event name from `X-Topic` and `event`, deduplicate on `X-Webhook-ID`, and respond within 5 seconds. Fifteen failed attempts on one event pause the whole endpoint, so after a long outage on your side, check that the endpoint is still active.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, verifies every signature, answers inside Modern Treasury's window, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Modern Treasury webhooks reliably in minutes.