Guide to Xero Webhooks: Features and Best Practices
Xero webhooks notify your application when accounting data changes: a contact is created, an invoice is updated, a credit note changes. If you're syncing Xero with another system or triggering workflows off accounting activity, webhooks are how you react without polling the API.
This guide covers how Xero webhooks work, the event categories, how to verify the x-xero-signature, the Intent to Receive handshake that activates a webhook, and the best practices for production.
What are Xero webhooks?
Xero webhooks are HTTP POSTs delivered to a URL you register in the developer portal, each carrying a thin payload: an events array where each event names a category, a type, and a resourceUrl you call (authenticated) to fetch the full record. Xero tells you that something changed, not the changed data itself.
Xero uses its own signature scheme (an x-xero-signature HMAC), and it won't activate a webhook until your endpoint proves it can verify signatures correctly, through the Intent to Receive handshake.
Xero webhook features
| Feature | Details |
|---|---|
| Configuration | Xero developer portal (one signing key per app) |
| Signature header | x-xero-signature |
| Signature scheme | HMAC-SHA256 over the raw body, base64-encoded |
| Activation | Intent to Receive (ITR): 200 on valid signature, 401 on invalid |
| Payload | Thin: events[] with resourceUrl, resourceId, eventType, eventCategory, tenantId |
| Categories | CONTACT, INVOICE, CREDITNOTE, SUBSCRIPTION |
| Types | CREATE, UPDATE (combined, e.g. CONTACT/CREATE) |
| Delivery | Batched events; retries with backoff |
| SDKs | xero-node, xero-python (no webhook-signature helper) |
Event categories
Xero events combine a category and a type. Each event in the batch points at the resource that changed:
| Event | Fires when |
|---|---|
CONTACT/CREATE | A contact is created |
CONTACT/UPDATE | A contact is updated |
INVOICE/CREATE | An invoice is created |
INVOICE/UPDATE | An invoice is updated |
CREDITNOTE/CREATE | A credit note is created |
CREDITNOTE/UPDATE | A credit note is updated |
SUBSCRIPTION/* | App/marketplace-billing subscription changes (tenantType APPLICATION) |
Each event carries resourceUrl, resourceId, eventDateUtc, eventCategory, eventType, tenantId, and tenantType. Fetch the actual record from resourceUrl with an authenticated API call.
Setting up Xero webhooks
Create a webhook in the Xero developer portal, choosing the categories you want. The portal shows a single signing key per app, used to verify deliveries. Before the webhook goes live, Xero runs the Intent to Receive validation (below); it won't activate until your endpoint passes.
Securing Xero webhooks
Every delivery carries an x-xero-signature header: a base64-encoded HMAC-SHA256 of the raw request body, keyed with your app's webhook signing key. Compute over the exact raw bytes (capture the body before any JSON middleware parses it) and compare in constant time.
The wrinkle unique to Xero is the Intent to Receive contract: during validation (and you should keep this behavior in production), your endpoint must return HTTP 200 when the signature matches and HTTP 401 when it doesn't. Xero sends a validation payload with an intentionally wrong signature too, and expects the 401. Get this contract right or the webhook never activates.
const crypto = require("crypto");
const SIGNING_KEY = process.env.XERO_WEBHOOK_KEY;
function isValid(rawBody, signature) {
const expected = crypto
.createHmac("sha256", SIGNING_KEY)
.update(rawBody)
.digest("base64");
const a = Buffer.from(expected);
const b = Buffer.from(signature || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
// Intent to Receive: 200 on a valid signature, 401 on an invalid one
if (!isValid(req.body, req.headers["x-xero-signature"])) {
return res.sendStatus(401);
}
res.sendStatus(200); // acknowledge fast
const { events } = JSON.parse(req.body);
for (const event of events) processQueue.add(event); // fetch resourceUrl async
});
The same check in Python:
import base64
import hashlib
import hmac
import os
SIGNING_KEY = os.environ["XERO_WEBHOOK_KEY"].encode()
def is_valid(raw_body: bytes, signature: str) -> bool:
digest = hmac.new(SIGNING_KEY, raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, signature or "")
The official xero-node and xero-python SDKs don't ship a webhook-signature helper, so you implement this check yourself.
Xero webhook limitations and pain points
Intent to Receive must return 200 and 401 correctly
The Problem: Xero won't activate a webhook until your endpoint returns 200 for a valid signature and 401 for an invalid one. An endpoint that returns 200 unconditionally (or 500 on a bad signature) fails ITR and the webhook never goes live.
Why It Happens: Xero validates that you actually verify signatures before it trusts you with events.
Workarounds:
- Implement the exact contract: 200 on match, 401 on mismatch, and keep it in production.
- Test ITR from the developer portal before relying on the webhook.
How Hookdeck Can Help: Hookdeck verifies the x-xero-signature at the edge and can satisfy the ITR contract, so your application receives pre-verified events without hand-rolling the 200/401 handshake.
Thin payloads require an authenticated fetch
The Problem: The payload carries only pointers (resourceUrl, resourceId). To do anything useful you make an authenticated API call per event, which adds latency and API load.
Why It Happens: Xero keeps payloads small and makes you pull the record on your schedule.
Workarounds:
- Acknowledge fast, then fetch
resourceUrlasynchronously. - Batch and cache fetches where possible.
How Hookdeck Can Help: Hookdeck durably queues each event so your worker fetches the resource at a controlled rate, and transformations can enrich events by calling the Xero API before they reach your app.
Capturing the raw body
The Problem: The signature is over the raw bytes. Middleware that parses and re-serializes the body first produces a different HMAC, so every delivery fails ITR and verification.
Why It Happens: Most frameworks parse JSON by default.
Workarounds:
- Capture the raw body (
express.raw,request.get_data()) and verify before parsing.
How Hookdeck Can Help: Hookdeck verifies against the bytes as received, so your app never wrestles with raw-body middleware.
Batching and retries mean duplicates
The Problem: Xero batches events and retries with backoff, so the same event can arrive more than once and a single POST can contain many events.
Why It Happens: Batching and at-least-once retries favor efficient, reliable delivery.
Workarounds:
- Iterate the full
eventsarray and process each independently. - Dedupe on
resourceIdpluseventDateUtc(or the resource's own updated timestamp).
How Hookdeck Can Help: Hookdeck deduplicates deliveries and can split batches, so your app processes one clean event at a time. See our guide to webhook idempotency.
Best practices
Implement the ITR 200/401 contract
Return 200 on a valid x-xero-signature and 401 on an invalid one, verifying the base64 HMAC-SHA256 over the raw body in constant time, and keep this behavior in production.
Acknowledge fast, fetch resourceUrl asynchronously
Return 200 immediately and defer the authenticated resourceUrl fetch to a queue. See why to process webhooks asynchronously.
Process the batch and dedupe
Iterate events and dedupe on resourceId + eventDateUtc so batching and retries don't double-process.
Guard the tenant
Each event carries a tenantId; route and authorize per tenant so a multi-org integration doesn't cross wires.
Make Xero webhooks production-ready
Hookdeck verifies x-xero-signature, satisfies Intent to Receive, deduplicates, and durably queues every event
Conclusion
Xero webhooks deliver accounting changes as thin, batched events you verify with an x-xero-signature HMAC over the raw body, and the webhook won't even activate until your endpoint passes the Intent to Receive handshake by returning 200 on a valid signature and 401 on an invalid one. On top of that you own the authenticated resourceUrl fetch, deduplication, and per-tenant routing.
Hookdeck verifies the signature, satisfies the ITR contract, deduplicates, and durably queues every event at the edge, so your app only ever processes verified, unique accounting events.
Get started with Hookdeck for free and handle Xero webhooks reliably in minutes.