Horizon

Test Brex webhooks locally

Receive Brex webhook events on localhost with a Horizon tunnel, and verify the Webhook-Signature header in a Next.js route handler.

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

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

Brex has no dashboard screen for webhooks. You register the endpoint with the Brex API.

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 Brex user token, generated from your Brex dashboard. See Brex's authentication guide.
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Brex signs each webhook with HMAC-SHA-256 (a keyed hash). Three headers arrive with it: Webhook-Id, Webhook-Timestamp and Webhook-Signature. The signed content is the ID, the timestamp and the raw body, joined with full stops: ${id}.${timestamp}.${body}. The key is your signing secret, base64 decoded. The header holds a space-separated list of v1,<base64 signature> entries, because Brex sends two signatures while it rotates a key.

Brex documents no verification helper in an SDK, so the handler does the check with node:crypto. It compares with timingSafeEqual and rejects timestamps more than 60 seconds off, as Brex's sample code does.

app/api/webhooks/brex/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TIMESTAMP_TOLERANCE_SECONDS = 60;

function isFreshTimestamp(timestamp: string) {
  const sentAt = Number.parseInt(timestamp, 10);
  if (Number.isNaN(sentAt)) {
    return false;
  }
  const now = Math.floor(Date.now() / 1000);
  return Math.abs(now - sentAt) <= TIMESTAMP_TOLERANCE_SECONDS;
}

function isValidSignature(signedContent: string, signatureHeader: string, secrets: string[]) {
  const receivedSignatures = signatureHeader
    .split(" ")
    .map((entry) => entry.split(",")[1])
    .filter((signature): signature is string => Boolean(signature))
    .map((signature) => Buffer.from(signature, "base64"));

  return secrets.some((secret) => {
    const expected = createHmac("sha256", Buffer.from(secret, "base64"))
      .update(signedContent)
      .digest();
    return receivedSignatures.some(
      (received) => received.length === expected.length && timingSafeEqual(received, expected),
    );
  });
}

export async function POST(request: Request) {
  const secrets = (process.env.BREX_WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
  if (secrets.length === 0) {
    return new Response("Missing BREX_WEBHOOK_SECRETS", { status: 500 });
  }

  const body = await request.text();
  const webhookId = request.headers.get("webhook-id") ?? "";
  const timestamp = request.headers.get("webhook-timestamp") ?? "";
  const signatureHeader = request.headers.get("webhook-signature") ?? "";

  const signedContent = `${webhookId}.${timestamp}.${body}`;
  const isValid =
    isFreshTimestamp(timestamp) && isValidSignature(signedContent, signatureHeader, secrets);

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

  const event = JSON.parse(body) as { event_type: string };
  console.log(`Received Brex event: ${event.event_type} (${webhookId})`);

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

Start the app:

npm run dev

You add the signing secret in a later step.

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-brex-app
Output
HORIZON: Tunnel connected
  URL          https://my-brex-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-brex-app

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

Register the endpoint with the Brex API

Call the register webhook endpoint with your tunnel URL and the event types you want. The Idempotency-Key header is required.

curl -X POST https://api.brex.com/v1/webhooks \
  -H "Authorization: Bearer $BREX_USER_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://my-brex-app.hrzn.run/api/webhooks/brex",
    "event_types": ["TRANSFER_PROCESSED", "TRANSFER_FAILED"]
  }'

Add the signing secret

Request the signing secrets from Brex. Each entry has a secret and a status.

curl https://api.brex.com/v1/webhooks/secrets \
  -H "Authorization: Bearer $BREX_USER_TOKEN"

Brex usually returns one secret. After a key rotation it returns two, the new one and the one about to be revoked. Put every secret in .env.local, separated by commas.

.env.local
BREX_WEBHOOK_SECRETS=replace-with-your-signing-secret

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

Check it works

Brex documents no test event and no resend button. Its webhooks fire when real events happen, such as TRANSFER_PROCESSED. To test your handler now, sign a sample body yourself the way Brex documents and send it through the tunnel. This checks your handler and the tunnel. It does not come from Brex.

SECRET="${BREX_WEBHOOK_SECRETS%%,*}"
BODY='{"event_type":"TRANSFER_PROCESSED","transfer_id":"dptx_test","company_id":"cuacc_test"}'
ID="msg_local_test"
TIMESTAMP=$(date +%s)
KEY_HEX=$(printf %s "$SECRET" | base64 -d | xxd -p -c 256)
SIGNATURE=$(printf %s "$ID.$TIMESTAMP.$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$KEY_HEX -binary | base64)

curl -X POST https://my-brex-app.hrzn.run/api/webhooks/brex \
  -H "Webhook-Id: $ID" \
  -H "Webhook-Timestamp: $TIMESTAMP" \
  -H "Webhook-Signature: v1,$SIGNATURE" \
  -H "Content-Type: application/json" \
  -d "$BODY"

The Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/brex

Your app terminal prints:

Output
Received Brex event: TRANSFER_PROCESSED (msg_local_test)

Troubleshooting

The signature doesn't match

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

  1. The secret. Brex says to base64 decode the secret before you use it as the HMAC key. The handler does that. Did you restart the dev server after you edited .env.local?
  2. The body. Sign the raw body. Don't call request.json() first, because any change to the bytes changes the signature.
  3. The rotation. During a key rotation Brex returns two secrets. Put both in BREX_WEBHOOK_SECRETS.
  4. The clock. The handler rejects a Webhook-Timestamp more than 60 seconds from your system time. Check that your computer's clock is correct.

The request returns 404

The Horizon line shows [404]. The route file app/api/webhooks/brex/route.ts serves /api/webhooks/brex. Check the registered 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. Brex still sends events to the old URL. Restart with -s, and update the registered URL with PUT /v1/webhooks/{id} if it differs.

Brex rejects the registration

Brex requires a valid HTTPS URL and an Idempotency-Key header. A Horizon tunnel URL is HTTPS.

Next steps

On this page