# Event webhooks

Xero events fanned out to as many of your own endpoints as you like, signed, retried and deduplicated.

## Why it exists

Xero delivers webhooks to one endpoint per application, which is the same bottleneck this platform exists to remove for API access. Events arrive here once, are matched to the organisation they belong to, and are delivered to every endpoint you have registered for that organisation and category.

## Subscribing

- Add an endpoint in the dashboard under Event webhooks. It must be https and publicly resolvable.
- Filter by event category (`INVOICE`, `CONTACT`, `SUBSCRIPTION`) or take everything, including categories Xero adds later.
- Filter by organisation, or take every organisation on the account, including ones connected later.
- Each subscription gets its own signing secret, its own retry queue and its own failure count.

## The delivery

One event per request. The resource URL is rewritten onto this proxy, so the endpoint can fetch it with the API key it already holds rather than needing a Xero connection of its own.

```json
{
  "deliveryId": "…",
  "subscriptionId": "…",
  "event": {
    "id": "…",
    "category": "INVOICE",
    "type": "UPDATE",
    "tenantId": "…",
    "resourceId": "…",
    "resourceUrl": "https://xero-proxy.fullstackplus.com.au/api.xro/2.0/Invoices/<id>",
    "eventDate": "2026-08-18T04:00:00.000Z",
    "receivedAt": "2026-08-18T04:00:01.000Z"
  }
}
```

| Header | Meaning |
| --- | --- |
| `X-XeroProxy-Signature` | `t=<unix seconds>,v1=<hex>`: HMAC-SHA256 of `<t>.<body>` under the subscription secret |
| `X-XeroProxy-Delivery` | Stable across retries: the idempotency key for your handler |
| `X-XeroProxy-Event` | The event this delivery carries; one event can go to several subscriptions |
| `X-XeroProxy-Tenant` | The Xero tenant id the event belongs to |

## Verifying a delivery

Verify before parsing, compare in constant time, and reject an old timestamp: because the timestamp is inside the signed string, a captured body cannot be replayed with a fresh one.

```javascript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((part) => part.split("=")),
  );
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!Number.isFinite(age) || age > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`, "utf8")
    .digest();
  const presented = Buffer.from(parts.v1 ?? "", "hex");

  return presented.length === expected.length
    && timingSafeEqual(presented, expected);
}
```

## Retries and duplicates

- Answer 2xx quickly. Anything else is retried six times over roughly seven hours: after 1, 5 and 15 minutes, then 1, 6 and 6 hours.
- An endpoint that fails 20 deliveries in a row is paused rather than retried forever. Events keep being recorded; re-enable it in the dashboard and re-queue what you need.
- Xero redelivers what it did not get a prompt answer for, so events are deduplicated on arrival. Deliveries are unique per subscription and event, which makes the fan-out safe to re-run.
- Delivery is at least once. Use the delivery id, or the event id, as your idempotency key.
- Events are kept for 30 days, then pruned along with their delivery history.

> An endpoint that has quietly stopped accepting deliveries is invisible from its own side, so the `webhook_delivery_failures` alert rule watches for it.
