Developer documentation
Deferred execution
Ask to be queued instead of refused: how a 429 becomes a 202 with a handle, and how to collect the answer.
Why it exists
A quota refusal is the right answer to *may I spend this budget right now* and the wrong answer to what you actually asked, which was *please carry this out*. The organisation's budget is not gone, it is spoken for: in ninety seconds, or at UTC midnight, it exists again.
Your application cannot use that fact. It has a 429 and a Retry-After, and its only move is to sleep and ask again, holding the work in a process that may not be running by then. This proxy can use it. It holds the connection, watches the organisation's remaining budget, knows when every limit resets, knows the priority of each key competing for the same pool, and can carry out a write exactly once even across a restart.
So a caller that opts in is answered 202 Accepted with a handle instead of 429 with a Retry-After. The request is held and carried out when the budget it needs is there.
Nothing changes unless you ask. Deferral needs the account to enable it AND the individual request to carry X-XeroProxy-Defer: allow. Without both, a refusal is exactly the 429 it always was. Xero's own 429s are never deferred: they still pass straight through.
Asking for it
# Ask to be queued rather than refused.
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"
# 202 Accepted
# X-XeroProxy-Deferred: 6f1c... <- the handle
# Location: /deferred/6f1c...
# Retry-After: 47
# Collect it. 202 means not yet; anything else IS the answer.
curl -s "https://xero-proxy.fullstackplus.com.au/deferred/6f1c..." \
-H "Authorization: Bearer $XERO_PROXY_KEY"A completed job hands back Xero's own status, content type and body, so whatever code you already wrote for the synchronous call handles the answer unchanged. GET /deferred lists everything this key has queued, and DELETE /deferred/<id> cancels a job that has not started yet.
Everything is scoped to the API key that created the job, never to the account. Another application on the same account cannot read your stored response body, even though the account owner can see both jobs in the dashboard.
Writes need an Idempotency-Key
A write is deferred only when it can be guaranteed to happen exactly once. That means idempotent writes enabled on the account and an Idempotency-Key on the request. Without both, a write is refused with the ordinary 429 rather than queued.
This is not a formality. A queue that might carry out a write twice is worse than no queue: it turns an error at your call site into a duplicate invoice found at reconciliation. The idempotency record is what lets a job survive a container dying mid-write, because the re-run finds the answer rather than repeating the request.
- Retrying a deferred write with the same key returns the SAME job id rather than queueing a second one. The response says
duplicate: true. - If budget frees up and your live retry goes through before the queue gets to it, the job notices and completes with that answer. The write still happens once.
- You do not have to poll at all for a write: retrying it normally once the queue has run is answered from the idempotency record as a replay.
States and refusals
| State | What it means |
|---|---|
queued | Accepted, waiting for budget. GET returns 202 with a Retry-After. |
running | Being carried out at Xero right now. Also a 202. |
completed | Done. The response is Xero's own, with X-XeroProxy-Deferred-State: completed. |
failed | Accepted and could not be carried out. deferred_failed, 502. |
expired | The budget never freed up within the account's window. deferred_expired, 410. |
cancelled | Cancelled before it started. deferred_cancelled, 410. |
When a refusal could NOT be deferred and you asked for it, the 429 carries X-XeroProxy-Defer saying which one thing would have changed the answer: not_enabled, writes_not_enabled, write_needs_idempotency, body_too_large or queue_full.
What is guaranteed, and what is not
- Queued work spends budget when it runs, not when it was accepted, and it is ordered by the same key priority that governs live traffic. Queueing is not a way to get ahead of it.
- Every gate is re-checked at execution against the state as it is then. Revoke a key, disconnect an organisation or withdraw a scope and its queued work fails rather than proceeding on a permission that no longer exists.
- The queue is bounded per account. A full queue refuses with the ordinary 429 and
queue_full, because a queue that grows forever hides a quota that is simply too small. - A job that waits longer than the account's window expires without running. Re-sending a write afterwards needs a new Idempotency-Key, because it is a new decision.
- Unlike a live call, a job absorbs a 429 or a 5xx from Xero and tries again within its attempt budget. You already accepted that the answer arrives later, so waiting is what you asked for.
- Stored request and response bodies are encrypted at rest with the same envelope scheme as connection tokens, and are deleted once the account's result window passes.
- The 202 is not a promise that the request will succeed. It is a promise that it will be attempted, once, and that you can find out what happened.
You do not have to poll to find out. Register a callback endpoint and this proxy will tell you the moment a job reaches a terminal state. See [completion callbacks](/developers/callbacks).