Developer documentation
Idempotent writes
How to retry a write that timed out without creating it twice, and what the proxy does with an Idempotency-Key.
Why it exists
Everything above this page has a reason to tell you to try again. A quota policy returns 429. Xero returns its own 429s, forwarded untouched. A connection in trouble returns 502. A network drops a response after the request that produced it has already been carried out.
On a read, retrying costs a little quota. On POST /api.xro/2.0/Invoices it is how an organisation ends up with the same invoice twice, and your application cannot prevent that on its own: from where it stands, *the answer never arrived* and *the write never happened* are the same observation.
The proxy can tell the two apart, because it is the thing in the middle. Send an Idempotency-Key on a write and the attempt is recorded before it is forwarded and the answer kept once it returns, so a retry carrying the same key is answered from the record instead of being sent to Xero a second time.
It is off until an account turns it on, in the dashboard under Write safety. While it is off the header is forwarded to Xero untouched, exactly as it always was, so nothing changes for software already in production until somebody decides it should.
Using it
- Generate one key per distinct write, before the first attempt. A UUID is the usual choice; anything printable with no spaces and up to 255 characters works.
- Reuse that key on every retry of that same write, and never for anything else.
- It applies to POST, PUT, PATCH and DELETE. GET and HEAD are idempotent already and the header is ignored on them.
KEY=$(uuidgen)
# Both calls create ONE invoice. The second is answered from the record.
for attempt in 1 2; do
curl -sD - -o /dev/null -X POST https://xero-proxy.fullstackplus.com.au/api.xro/2.0/Invoices \
-H "Authorization: Bearer $XERO_PROXY_KEY" \
-H "Xero-Tenant-Id: $TENANT" \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"Type":"ACCREC","Contact":{"ContactID":"..."},"LineItems":[]}' \
| grep -i x-xeroproxy-idempot
doneThe header is also forwarded to Xero, so Xero's own idempotency support still applies on the endpoints that have it. This is deliberately belt and braces: ours covers every endpoint and every failure mode, including the ones where the request never left this platform.
What you get back
| `X-XeroProxy-Idempotency` | What happened |
|---|---|
stored | The write went to Xero and its answer was recorded. A retry with this key gets that answer. |
replay | The write was NOT carried out again. The body is the answer the original attempt produced, and X-XeroProxy-Idempotent-Replay: true is set alongside it. |
released | Nothing worth replaying came back (a 429, a 5xx, or Xero was never reached). The record was dropped, so your retry is a first attempt again. |
off | Idempotent writes are not enabled on this account. The key was forwarded to Xero and nothing else was done with it. |
A replay carries no rate-limit or budget headers, and spends no rate token, no daily quota and no reserve. It reached no Xero endpoint, so it costs nothing, exactly like a cache hit.
When it refuses
| Code | Status | Meaning |
|---|---|---|
idempotency_in_progress | 409 | The first attempt is still running. Sending it again is the duplicate the key exists to prevent, so it is refused with a Retry-After rather than forwarded. |
idempotency_conflict | 422 | This key was already used for a different request. The response names the method and path it was first used for. |
idempotency_body_too_large | 413 | A request sent with a key has to be read whole to be fingerprinted, and this one is over the account's limit. Send it without the header, or raise the limit. |
idempotency_key_invalid | 400 | The header was empty, too long, or contained a space or a non-printable character. |
A conflict is protecting you. Answering a different request with the first one's response would be silently wrong, which is worse than the duplicate write this feature exists to prevent. If it fires repeatedly, the application is generating keys from something that is not the request, and the account gets an alert saying so.
What is guaranteed, and what is not
- The claim on a key is a single atomic insert, so two retries arriving at the same instant cannot both be treated as the first.
- Only an answer is kept. A 429 or a 5xx means *ask again*, and storing one would turn a transient fault into a permanent wrong answer.
- A 400 from Xero IS kept: repeating a malformed request produces the same refusal, and possibly a second record if it was only half malformed.
- Records expire after the account's retention window, 24 hours by default. After that the same key is a first attempt again.
- Stored bodies are encrypted at rest with the same envelope scheme as connection tokens. Only the status, the media type and the body are kept, never the rest of the response.
- A container that dies mid-write leaves a claim behind; another attempt may take it over after the account's claim timeout, 90 seconds by default.
- This protects a write through this proxy. It cannot deduplicate a write your application also makes through some other route into Xero.