Horizon

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.

app/api/webhooks/xero/route.ts
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.

.env.local
XERO_WEBHOOK_KEY=replace-with-your-webhook-key

Start the app on port 3000.

npm run dev

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

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

  1. Open your app in the Xero developer portal and go to its webhooks settings.
  2. Choose the event categories to receive. Xero offers CONTACT, INVOICE, CREDITNOTE, SUBSCRIPTION, PREPAYMENT and OVERPAYMENT.
  3. Enter the endpoint URL.
  4. Copy the webhook key into XERO_WEBHOOK_KEY in .env.local, then restart npm run dev.
  5. 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.

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:

Output
  POST    200  /api/webhooks/xero
  POST    401  /api/webhooks/xero

The 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:

Output
Received Xero event: CONTACT UPDATE <contact id>

Troubleshooting

The Intent to receive check fails

  • Check that the Horizon tunnel and npm run dev are both running before you save the webhook.
  • Check that XERO_WEBHOOK_KEY matches the key in the developer portal, with no extra spaces or newline.
  • Restart npm run dev after you edit .env.local.
  • Check that the handler returns 401, not 400, for a bad signature.

The signature doesn't match

  • Compute the HMAC over the raw body. Don't run JSON.parse and JSON.stringify first, 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 for Reconnected.
  • 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 resourceUrl of an event to fetch the changed resource from the Xero API. Events carry identifiers, not the full resource.

On this page