---
title: "Idempotency"
description: "Make a retried write safe: send an Idempotency-Key and a second attempt replays the first answer instead of creating a second record."
canonical: https://developer.billabex.com/en/guides/idempotency/
lang: en
alternate: https://developer.billabex.com/fr/guides/idempotence/
last-updated: 2026-09-18
---

# Idempotency

> Make a retried write safe: send an Idempotency-Key and a second attempt replays the first answer instead of creating a second record.

Source: https://developer.billabex.com/en/guides/idempotency/
Language: English (en)
French version: https://developer.billabex.com/fr/guides/idempotence/

A retry is not an exception, it is the normal ending of a request that timed out. The connection
drops, the process restarts, the proxy gives up: the call may or may not have reached Billabex, and
your client cannot tell which. Without a way to mark the second attempt as the same attempt, both
answers are wrong. Retrying may create a second invoice; not retrying may lose the first one.

`Idempotency-Key` removes the choice. Send one on a write, and a second request carrying the same
key returns the stored response of the first instead of executing again.

## How to use it

Send the header on any `POST`, `PUT`, `PATCH` or `DELETE` under `/api/public/v1/`, with a value
unique to that request. A UUID generated once, before the first attempt, and reused on every retry
of it. Here with the invoice creation of the [Getting Started guide](/en/guides/getting-started/):
`POST /api/public/v1/invoices`, as `multipart/form-data`, with the file, the date components and
the amounts:

```bash
# Generated once, before the first attempt, and stored with the invoice being synced.
KEY=$(uuidgen)

curl -i -X POST https://next.billabex.com/api/public/v1/invoices \
  -H "Authorization: Bearer $BILLABEX_ACCESS_TOKEN" \
  -H "Idempotency-Key: $KEY" \
  -F "accountId=$ACCOUNT_ID" \
  -F "number=INV-2026-114" \
  -F "issuedDate.year=2026" -F "issuedDate.month=9" -F "issuedDate.day=15" \
  -F "dueDate.year=2026" -F "dueDate.month=10" -F "dueDate.day=15" \
  -F "totalAmount=1200.00" \
  -F "taxAmount=200.00" \
  -F "billingAddress.street=1 Example Street" \
  -F "billingAddress.city=Paris" \
  -F "billingAddress.postalCode=75001" \
  -F "billingAddress.country=FR" \
  -F "file=@invoice.pdf;type=application/pdf"
```

Do not set the `Content-Type` header yourself: `curl -F`, like `fetch` with a `FormData` body,
writes `multipart/form-data` along with the boundary that separates the fields. A due date is sent
as three fields, `dueDate.year`, `dueDate.month` and `dueDate.day`, never as one `2026-10-15`
string. After a timeout or a dropped connection, send the same command again with the same `$KEY`.
`curl` picks a new boundary on every run, and that is fine: Billabex compares the fields and the
bytes of the file, not the raw body, so the retry is recognised as the same request.

The key belongs to the request, not to the session. Generate it where you build the request, store
it next to whatever you are syncing, and reuse it for every retry of that one write. A key generated
inside the retry loop is a new key each time, which is the same as sending none.

The header is optional. A request without one behaves exactly as it always has: it executes every
time it is sent.

## What a second attempt gets

A replayed response carries the status and the body of the first attempt, plus one header:

```http
HTTP/1.1 201 Created
Idempotent-Replay: true
```

Read `Idempotent-Replay` when you need to tell a fresh write from a replayed one, to decide whether
to log a creation. Do not branch your business logic on it: the body is the same either way, and
that is the point.

A key is remembered for **24 hours**, and is scoped to your OAuth client, to the HTTP method and to
the path. Two clients sending the same key never see each other's responses, and the same key on
`POST /invoices` and on `POST /credit-notes` is two different operations.

## Errors

| Status | When                                                                 | What to do                                                                             |
| ------ | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `400`  | The key is empty or longer than 255 characters.                      | Send a UUID or a similar short unique value.                                           |
| `409`  | A request with this key is still running.                            | Wait for the `Retry-After` delay, then retry with the same key to read its result.     |
| `422`  | This key was already used with a different request body.             | Use a new key for a new request. Resend the original body to read the stored response. |
| `503`  | The idempotency store could not be reached, so nothing was executed. | Retry unchanged, with the same key.                                                    |

The `409` matters when your client runs several workers. Two of them retrying the same key at the
same moment must not both write, so the second one is told to wait rather than allowed to run beside
the first.

The `422` is a bug report, not a refusal to help: answering with the earlier response would hand you
the result of a request you did not send. It means the key was reused for something else, usually
because it was derived from something not unique enough, such as a customer id rather than the
operation.

A `503` means the request was **not** executed. It is the one case where refusing is safer than
trying: running the write while unable to record that it ran is exactly how the duplicate this
header prevents gets created.

## What is not covered

- **Reads.** `GET` and `HEAD` change nothing, so there is nothing to make safe. The header is
  ignored on them.
- **Failures.** A write that answered `4xx` or `5xx` releases its key: retrying with the same key
  runs it again, which is what you want, since nothing worth replaying happened.
- **The MCP server.** MCP tools carry an `idempotentHint` annotation saying whether calling a tool
  twice has the same effect as calling it once. Read it from
  [the tool catalogue](https://next.billabex.com/mcp/tools) before retrying a tool call.

## Writes that are already safe

Some writes need no key because repeating them cannot produce a second record:

- **Upserts by `sourceId`.** `sourceId` is unique per organization, so sending the same document
  twice updates it rather than duplicating it. See the [upsert](../upsert/) guide.
- **`DELETE`.** Deleting something already deleted answers the same way.
- **Setting a value.** `PATCH` of a paid amount or a due date sets a value rather than adding to
  one.

Use a key anyway when you want the _response_ of the first attempt, not only its effect: an upsert
retried after a timeout tells you nothing about which of the two attempts created the record.

---

Billabex developer portal. OpenAPI specification: https://developer.billabex.com/openapi.json.
Agent instructions: https://developer.billabex.com/llms.txt. Complete documentation: https://developer.billabex.com/llms-full.txt.
