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_ plus "event": "reconciled" is an expected_ 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_, 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
{
"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
- In the Modern Treasury app, open Developer settings and go to the webhooks page.
- Add an endpoint with your HTTPS URL. Give test traffic its own URL if you want to keep it apart.
- Choose the events the endpoint should receive. Modern Treasury recommends subscribing only to the events you need.
- Copy the webhook key from the same settings and store it server-side, for example as
MODERN_.TREASURY_ WEBHOOK_ KEY - 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.
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:
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 verifies the
X-Signatureheader, 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-Topicandevent, and fall back todata.objectif the header is missing. - Read headers in lowercase, or with a case-insensitive helper such as
req.get(). - Check that
data.objectagrees withX-Topicbefore acting, since only the body is signed.
How Hookdeck Can Help: Hookdeck 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.
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.
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 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 for free and handle Modern Treasury webhooks reliably in minutes.