Gareth Wilson Gareth Wilson

Guide to Xero Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationXero developer portal (one signing key per app)
Signature headerx-xero-signature
Signature schemeHMAC-SHA256 over the raw body, base64-encoded
ActivationIntent to Receive (ITR): 200 on valid signature, 401 on invalid
PayloadThin: events[] with resourceUrl, resourceId, eventType, eventCategory, tenantId
CategoriesCONTACT, INVOICE, CREDITNOTE, SUBSCRIPTION
TypesCREATE, UPDATE (combined, e.g. CONTACT/CREATE)
DeliveryBatched events; retries with backoff
SDKsxero-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:

EventFires when
CONTACT/CREATEA contact is created
CONTACT/UPDATEA contact is updated
INVOICE/CREATEAn invoice is created
INVOICE/UPDATEAn invoice is updated
CREDITNOTE/CREATEA credit note is created
CREDITNOTE/UPDATEA 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 resourceUrl asynchronously.
  • 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 events array and process each independently.
  • Dedupe on resourceId plus eventDateUtc (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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.