How Xero delivers webhooks

Xero wants a 200 within 5 seconds with no body, and a 401 for a bad signature before it switches a webhook on. The verified format and a Node verifier.

Delivery behaviour

Read from the vendor's own documentation, last checked 2026-08-06. Where the vendor states no number, this table carries none.

Response budget5s

200 within 5 seconds, with no body and no cookies; 401 when the signature is wrong

Check whether your handler answers in time

Retrynot published by the vendor
Gives upnot published by the vendor
Sourcehttps://developer.xero.com/documentation/guides/webhooks/configuring-your-server/

Signature

Verified against a working verifier proven by a test suite, not read from documentation. Last verified 2026-08-20.

AlgorithmHMAC-SHA256
Signed payloadraw body
Encodingbase64
Headerx-xero-signature = base64
Tolerancenone enforced

Verify it

The raw request body, byte for byte, before any JSON parsing. Every scheme on this page breaks the moment a framework re-serializes the payload.

const crypto = require("node:crypto");

// base64(HMAC-SHA256(webhooks key, raw body))
const expected = crypto
  .createHmac("sha256", webhooksKey) // developer.xero.com → My Apps → Webhooks
  .update(rawBody)
  .digest("base64");
const received = Buffer.from(req.headers["x-xero-signature"] ?? "");
const wanted = Buffer.from(expected);
// timingSafeEqual throws on a length mismatch, so check that first.
const valid =
  received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
// Intent to Receive needs both answers: 200 (empty body) when valid, 401 when not.

What bites

The webhook stays off until Intent to Receive passes: Xero sends a correctly signed delivery and a deliberately mis-signed one, and wants 200 for the first and 401 for the second. An endpoint that answers 200 to everything never goes live. With the Xero source and the webhooks key set, AnyHook answers both itself and stores neither.

A delivery is a batch of pointers, not records: each event names a category, a type and a resourceUrl to fetch with an authenticated call. AnyHook names the event after the first one in the batch (invoice.update), so filters and the event log read the same way.

Read more

AnyHook sits in front of endpoints that receive from Xero: it answers in about a second, retries on its own schedule when your server is down, and keeps every event replayable. Change one URL, keep your code.

How it works →