Horizon

Test Modern Treasury webhooks locally

Receive Modern Treasury webhook events on localhost with a Horizon tunnel, and verify the X-Signature header with the official Node SDK.

Receive Modern Treasury events on your laptop while you build, with a public HTTPS URL Modern Treasury can reach.

Horizon has no Modern Treasury integration. Modern Treasury sends webhooks to a public URL, and Horizon provides that URL.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Modern Treasury organization where you can open Developers
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Modern Treasury signs every payload with HMAC-SHA-256 (a keyed hash) of the raw body, keyed with your webhook key. The signature is hex encoded and arrives in the X-Signature header. The official modern-treasury Node SDK has webhooks.validateSignature to check it. Install it:

npm install modern-treasury

Create the route handler. Read the raw body with request.text() and don't parse or change it before you validate. The SDK client also needs your API key and organization ID, so you set three environment variables.

app/api/webhooks/modern-treasury/route.ts
import ModernTreasury from "modern-treasury";

export async function POST(request: Request) {
  const client = new ModernTreasury();
  const body = await request.text();

  let isValid = false;
  try {
    isValid = client.webhooks.validateSignature(body, request.headers);
  } catch {
    isValid = false;
  }

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const topic = request.headers.get("x-topic");
  const webhookId = request.headers.get("x-webhook-id");
  console.log(`Received Modern Treasury webhook: ${topic} (${webhookId})`);

  return new Response("ok", { status: 200 });
}

new ModernTreasury() reads its settings from the environment. validateSignature throws when the X-Signature header is missing, so the handler catches the error and rejects the request.

Modern Treasury sends each webhook with a unique X-Webhook-ID header. The ID stays the same when Modern Treasury sends a webhook again, so you can use it to process each webhook once.

Add your credentials to .env.local. You add the webhook key in a later step.

.env.local
MODERN_TREASURY_API_KEY=replace-with-your-api-key
MODERN_TREASURY_ORGANIZATION_ID=replace-with-your-organization-id

Start the app:

npm run dev

Start a tunnel

In a second terminal, open a tunnel to port 3000 on a subdomain you reserved, with -s:

hrzn tunnel http://localhost:3000 -s my-treasury-app
Output
HORIZON: Tunnel connected
  URL          https://my-treasury-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-treasury-app

Use -s. Without it, the subdomain is random and changes on every run, and you would have to edit the endpoint in Modern Treasury each time you restart. Reserved subdomains are a paid feature, see Pricing.

Add the endpoint in Modern Treasury

  1. Open the Developers section in Modern Treasury and select the Webhooks tab.
  2. Select Create New Webhook Endpoint.
  3. Set the URL to https://my-treasury-app.hrzn.run/api/webhooks/modern-treasury.
  4. Choose which events the endpoint receives. If you choose individual events, Modern Treasury shows a list of every event it sends.
  5. Save the endpoint and copy the webhook key.

Verify the signature

Add the webhook key to .env.local:

.env.local
MODERN_TREASURY_API_KEY=replace-with-your-api-key
MODERN_TREASURY_ORGANIZATION_ID=replace-with-your-organization-id
MODERN_TREASURY_WEBHOOK_KEY=replace-with-your-webhook-key

Restart npm run dev so Next.js loads the new variable.

Check it works

Create an event in your Modern Treasury account, such as a new payment order. Modern Treasury sends a webhook for it to your endpoint.

The Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/modern-treasury

Your app terminal prints one line such as Received Modern Treasury webhook: payment_order (<webhook id>).

To see the delivery on Modern Treasury's side, open Developers, then Webhooks, and select your endpoint. Its overview page lists the delivery attempts. Select View Details on one to see its headers, body and destination URL.

Troubleshooting

The signature doesn't match

The Horizon line shows [401]. Check these in order:

  1. The key. MODERN_TREASURY_WEBHOOK_KEY must equal the webhook key of this endpoint. Each endpoint has its own key. Did you restart the dev server after you edited .env.local?
  2. The body. Validate the raw body. Don't call request.json() first.
  3. The header. Modern Treasury sends the signature in X-Signature.

The handler throws about a missing API key or organization ID

new ModernTreasury() needs MODERN_TREASURY_API_KEY and MODERN_TREASURY_ORGANIZATION_ID as well as the webhook key. Set all three in .env.local.

The request returns 404

The Horizon line shows [404]. The route file app/api/webhooks/modern-treasury/route.ts serves /api/webhooks/modern-treasury. Check the endpoint URL for typos, and make sure the file exports POST.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Modern Treasury still sends events to the old URL. Restart with -s, and edit the endpoint if the URL differs.

Next steps

On this page