Xero Proxy

Developer documentation

Completion callbacks

Be told when deferred work finishes, instead of polling for it: registering an endpoint, verifying the signature, and what a callback deliberately does not contain.

View as markdown

Why it exists

Deferred execution exists because a 429 leaves your application holding the work in a process that may not be running when the budget comes back. Handing you a 202 and a job id solves half of that. The work moved here; the *waiting* stayed with you.

A caller that only has a handle has to keep a process alive, remember the id, and poll on a cadence it has to guess - which is the same process, the same handle and the same guessing the 429 required, now wearing a different status code. A job can also sit for the whole of your account's deferral window, so the poll loop is longer-lived than the one it replaced, not shorter.

This proxy knows the instant a job reaches a terminal state. Register an endpoint and it says so.

Callbacks are additive. Polling /deferred/<id> works exactly as it did and is still the way you collect the answer. What a callback removes is the need to *ask repeatedly whether there is one yet*.

Registering an endpoint

Endpoints are registered by an account owner on the Callbacks page in the dashboard, not by the API. This is deliberate and it is the security boundary of the whole feature: the URL decides where a message signed by this platform is sent, so a proxied request is never allowed to name it. If it could, every API key would be a way to make this proxy call an address of the caller's choosing with our signature attached.

  • The URL must be https, a fully qualified public hostname, and carry no credentials in the URL itself. The same check the event fan-out applies.
  • An endpoint hears about every key on the account by default, or you can restrict it to named keys. An account running an invoicing service and a reporting service can give each its own endpoint, and neither is told about the other's work.
  • An endpoint registered today hears about work that finishes from now on. It is not opened with a backlog of everything it missed.
  • Twenty consecutive failed deliveries pauses an endpoint. Pausing means *stop telling me*, not *hold them*: resuming does not replay what was skipped.

Correlating a callback to your own record

The job id is in every callback, and for a caller that stored it that is enough. When the process that receives callbacks is not the process that sent the request - which is the reason this feature exists - put your own identifier on the request instead and read it back:

curl -sD - -o response.json \
  "https://xero-proxy.fullstackplus.com.au/api.xro/2.0/Invoices?page=1" \
  -H "Authorization: Bearer $XERO_PROXY_KEY" \
  -H "Xero-Tenant-Id: $TENANT" \
  -H "X-XeroProxy-Defer: allow" \
  -H "X-XeroProxy-Callback-Token: run-2026-08-20-invoices"

Whatever you send comes back verbatim as deferred.token. It is an opaque correlation handle of at most 200 printable ASCII characters, it is stored in the clear, and it is sent over the network to your endpoint, so it is not a credential and must not be used as one. A malformed token is refused with callback_token_invalid on the request that sent it, rather than dropped and discovered missing hours later.

Retrying a deferred write with the same Idempotency-Key returns the job you already have, and the token stays the one that created it. There is one job, so there is one completion.

What arrives

A POST with a JSON body, sent when the job reaches completed, failed, expired or cancelled.

{
  "deliveryId": "9b2f...",
  "endpointId": "41a0...",
  "deferred": {
    "id": "6f1c...",
    "state": "completed",
    "request": { "method": "GET", "path": "/api.xro/2.0/Invoices" },
    "keyId": "k_7f21a9",
    "reason": "org_reserve",
    "attempts": 1,
    "acceptedAt": "2026-08-20T04:11:07.000Z",
    "completedAt": "2026-08-20T04:13:41.000Z",
    "expiresAt": "2026-08-20T05:11:07.000Z",
    "status": 200,
    "failure": null,
    "token": "run-2026-08-20-invoices",
    "collect": "https://xero-proxy.fullstackplus.com.au/deferred/6f1c...",
    "documentation": "https://xero-proxy.fullstackplus.com.au/developers/callbacks"
  }
}

state: completed means the request reached Xero and there is an answer; status is Xero's own status code for it, so a receiver can decide whether collecting it is worth a round trip. The other three states mean there is no answer, and failure says why.

A callback never contains the response body

This is a rule, not an omission, and it will not be relaxed.

The answer to a deferred request belongs to the API key that created the job. That is what /deferred/<id> enforces, and it is why another application on the same account cannot read your stored response body even though your account owner can see both jobs listed in the dashboard.

A callback endpoint is registered by the account owner. Pushing the body to it would hand one application's financial data to an address chosen by somebody else, and would do it without the credential check that rule depends on. So the callback tells you the work finished and where the answer is; you collect it from collect with the same key that queued it, exactly as before.

Verifying the signature

Every callback carries X-XeroProxy-Signature: t=<unix seconds>,v1=<hex>, an HMAC-SHA256 over <t>.<raw body> using your endpoint's signing secret. It is the same scheme the event fan-out uses, so one verifier covers both.

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

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((pair) => pair.split("=")),
  );
  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`, "utf8")
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return false;

  // The timestamp is inside the signed string, so a captured body cannot be
  // replayed later with a fresh one. Rejecting an old t is replay protection.
  return Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
}

The secret is shown on the Callbacks page in the dashboard. It is derived rather than stored, so there is no copy of it at rest, and it is never returned by any API or MCP tool.

Delivery, retries and what is guaranteed

  • Answer with any 2xx. Anything else, or a timeout after 10 seconds, is a failure.
  • Six attempts over about seven hours: 1 minute, 5, 15, 1 hour, then 6 hourly. X-XeroProxy-Delivery and X-XeroProxy-Deferred are stable across every attempt, so you can be idempotent without hashing the body.
  • At-least-once, not exactly-once. A receiver that answers slowly and then succeeds may be sent the same delivery again.
  • A callback is never the only record. If it never arrives, the job and its answer are still there to be collected until your account's result window passes, and the account owner sees every delivery attempt in the dashboard.
  • Deliveries are queued and sent by a scheduled task, never on the request path, so a slow endpoint of yours never slows a proxied call of yours.

An endpoint that has quietly stopped accepting callbacks is invisible from its own side, and the software waiting on them has by design stopped polling. That is what the Completion callbacks failing alert rule is for; leave it enabled.