Gareth Wilson Gareth Wilson

Guide to Modern Treasury Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDeveloper settings > Webhooks, for the whole organisation
Live and testSeparate URLs, or one URL and the X-Live-Mode header
Body{ "event": <action>, "data": <object> }
Event nameX-Topic header plus the body's event
Signature headerX-Signature
SchemeHMAC-SHA256, hex digest
Signed contentThe raw request body, nothing else
SecretThe webhook key in Developer settings, one per organisation. Rotation is through support
Idempotency keyX-Webhook-ID, stable across retries
Replay windowNone. X-Event-Time is sent but not signed
Response window5 seconds, for a 2xx
RetriesExponential backoff, up to 15 attempts over roughly a day and a half
Exhausted retriesThe endpoint is paused
Source IPsPublished on Modern Treasury's IP addresses page
TransportHTTPS 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-TopicActions (event)What they tell you
payment_ordercreated, approved, begin_processing, finish_processing, acknowledged, confirmed, completed, reconciled, failed, returned, cancelled, held, resolved and moreWhere an outgoing payment is in its life, from approval to the bank and back
expected_paymentcreated, reconciled, partially_reconciled, tentatively_reconciled, overdue, unreconciled, archivedWhether money you expect to receive has matched a transaction
incoming_payment_detailcreated, completed, reconciled, tentatively_reconciled, failed, returned, return_failed, unreconciledAn incoming payment's progress
transactioncreated, updated, reconciledBank transactions appearing and being matched
returncreated, begin_processing, finish_processing, completed, reconciled, failed, returned and moreReturns of payments
reversalcreated, begin_processing, finish_processing, completed, failed, returned, unreconciledPayment 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
{
  "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.

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


Gareth Wilson

Gareth Wilson

Product Marketing

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