Test Xero webhooks locally
Receive Xero webhook events on localhost with a Horizon tunnel, pass the Intent to receive check, and verify the x-xero-signature header.
Receive Xero events on your laptop while you build, with a URL Xero can reach.
Xero checks your endpoint when you save it. This check is called Intent to receive. Your handler must pass it before Xero sends any real event, so the tunnel and the handler need to be running first.
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 Next.js app that uses the App Router and runs on port 3000
- A Xero app in the Xero developer portal, and a Xero organisation to change data in, such as the demo company
Start your app
Create a route handler. Xero sends the x-xero-signature header. Its value is the HMAC (a keyed hash) of the raw request body, computed with SHA-256 and your webhook key, then encoded as base64.
The same handler answers the Intent to receive check and real events. Xero expects 200 when the signature matches and 401 when it doesn't. Read the raw body with request.text() before you parse it, and compare with crypto.timingSafeEqual.
import { createHmac, timingSafeEqual } from "node:crypto";
type XeroWebhookPayload = {
events?: { eventCategory: string; eventType: string; resourceId: string }[];
};
export async function POST(request: Request) {
const webhookKey = process.env.XERO_WEBHOOK_KEY;
if (!webhookKey) {
return new Response("Missing XERO_WEBHOOK_KEY", { status: 500 });
}
const body = await request.text();
const received = request.headers.get("x-xero-signature") ?? "";
const expected = createHmac("sha256", webhookKey).update(body).digest("base64");
const receivedBuffer = Buffer.from(received);
const expectedBuffer = Buffer.from(expected);
const isValid =
receivedBuffer.length === expectedBuffer.length &&
timingSafeEqual(receivedBuffer, expectedBuffer);
if (!isValid) {
return new Response(null, { status: 401 });
}
const payload = JSON.parse(body) as XeroWebhookPayload;
for (const event of payload.events ?? []) {
console.log(`Received Xero event: ${event.eventCategory} ${event.eventType} ${event.resourceId}`);
}
return new Response(null, { status: 200 });
}Xero shows the webhook key in the developer portal when you configure webhooks. Add it to .env.local. You copy it in a later step.
XERO_WEBHOOK_KEY=replace-with-your-webhook-keyStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so Xero would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Add the webhook in Xero
Your endpoint URL is the tunnel URL plus the route path: https://my-app.hrzn.run/api/webhooks/xero.
- Open your app in the Xero developer portal and go to its webhooks settings.
- Choose the event categories to receive. Xero offers
CONTACT,INVOICE,CREDITNOTE,SUBSCRIPTION,PREPAYMENTandOVERPAYMENT. - Enter the endpoint URL.
- Copy the webhook key into
XERO_WEBHOOK_KEYin.env.local, then restartnpm run dev. - Save the webhook. Xero now sends the Intent to receive check to your URL.
Set the key before you save, or the check fails. A failed check leaves the webhook inactive.
Xero expects 200 when the signature is valid and 401 when it isn't. Don't return 200 without verifying the signature.
Check it works
After you save the webhook, the Horizon terminal prints at least one line for the Intent to receive check. Expect 200 for a request with a valid signature. A 401 line means Xero sent a request that failed verification:
POST 200 /api/webhooks/xero
POST 401 /api/webhooks/xeroThe webhook becomes active when the check passes.
Then trigger a real event. Create or update a contact in your Xero organisation. A delivery can take a moment. Your app terminal prints:
Received Xero event: CONTACT UPDATE <contact id>Troubleshooting
The Intent to receive check fails
- Check that the Horizon tunnel and
npm run devare both running before you save the webhook. - Check that
XERO_WEBHOOK_KEYmatches the key in the developer portal, with no extra spaces or newline. - Restart
npm run devafter you edit.env.local. - Check that the handler returns
401, not400, for a bad signature.
The signature doesn't match
- Compute the HMAC over the raw body. Don't run
JSON.parseandJSON.stringifyfirst, because that can change the bytes. - Encode the digest as base64, not hex.
The request returns 404
The Horizon line shows [404]. The endpoint URL doesn't match your route. The file app/api/webhooks/xero/route.ts serves /api/webhooks/xero, and Xero sends POST requests.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that the contact you changed is in the organisation that your app is connected to, and that the event category is selected on the webhook.
Next steps
- Read Xero's webhooks documentation.
- Use the
resourceUrlof an event to fetch the changed resource from the Xero API. Events carry identifiers, not the full resource.
Test Worldline webhooks locally
Receive Worldline Direct webhook events on localhost with a Horizon tunnel, and verify the X-GCS-Signature header in a Next.js route handler.
Test AfterShip webhooks locally
Receive AfterShip Tracking webhook events on localhost with a Horizon tunnel, and verify the aftership-hmac-sha256 signature.