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.
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 devYou 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-appHORIZON: Tunnel connected
URL https://my-brex-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-brex-appUse -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"]
}'Brex allows one webhook endpoint per customer (client_id). If you already registered one, update it with PUT /v1/webhooks/{id} instead, so it points at your tunnel.
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.
BREX_WEBHOOK_SECRETS=replace-with-your-signing-secretRestart 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:
POST 200 /api/webhooks/brexYour app terminal prints:
Received Brex event: TRANSFER_PROCESSED (msg_local_test)Troubleshooting
The signature doesn't match
The Horizon line shows [401]. Check these in order:
- 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? - The body. Sign the raw body. Don't call
request.json()first, because any change to the bytes changes the signature. - The rotation. During a key rotation Brex returns two secrets. Put both in
BREX_WEBHOOK_SECRETS. - The clock. The handler rejects a
Webhook-Timestampmore 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
- Read Brex's webhooks guide.
- See the payload of each event in the Webhooks API reference.
- Reserve a subdomain so your URL never changes: see pricing.