Idempotency
Replay a write without creating a duplicate with X-Client-Mutation-Id, and protect a modification with the X-Base-Version optimistic lock.
A network can drop between the sending of your request and the receipt of the response. You then do not know whether the invoice was created. Idempotency answers that question: you resend the same request with the same key, and NovaFisko guarantees that it is executed only once.
The X-Client-Mutation-Id key
Add the X-Client-Mutation-Id header to every write request (POST, PUT, PATCH, DELETE) that targets a company, that is, under companies/{company}.
curl -X POST https://api.novafisko.com/v1/companies/k3Jd9fPq2LmX/entries/invoice \
-H "Authorization: Bearer $NOVAFISKO_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Client-Mutation-Id: 0b0f7a52-63f4-4f1f-8a43-0d4f6b0b8a10" \
-d '{
"journal_id": 3,
"third_party_id": 41,
"entry_date": "2026-10-02",
"reference": "F-2026-0918",
"lines": [{"account": "604000", "amount": "1250.00", "vat_code": "A21"}]
}'
Rules
| Rule | Detail |
|---|---|
| Format | Free string of 1 to 64 characters. A version 4 UUID is a perfect fit |
| Scope | The key is unique per company |
| Storage | Only successful responses (2xx status) are stored |
| Requests concerned | POST, PUT, PATCH, DELETE under companies/{company} |
| Exception | sync/push handles its own idempotency, mutation by mutation |
An empty key or one longer than 64 characters is ignored: the request runs normally, without protection.
First send
The request runs. If it succeeds, NovaFisko stores the status and the body of the response.
HTTP/1.1 201 Created
X-Operation-Id: 5d0f6a7e-2f0b-4a35-9b56-7f1f3c1f4c22
Replay
If you send the same key again on the same company, nothing is executed. You receive the stored body, with status 200 and a header that signals the replay.
HTTP/1.1 200 OK
X-Idempotent-Replay: 1
Content-Type: application/json
A replay always responds 200, even if the original request responded 201 or 204. Treat any 2xx status as a success and check X-Idempotent-Replay if you need to tell the two cases apart.
What is not stored
A request that fails (4xx, 5xx) is not stored. You can fix the data and resend the same key, or retry as is after a 500 or 503 error.
The stored body is limited to 500 KB. Beyond that, the replay returns 200 with a null body: the operation did take place, read the resource again to get its state.
Choosing the key well
- Generate the key before the first send and save it with the operation in your system.
- A key identifies an intent, not an attempt: all attempts of the same operation share the same key.
- Never reuse a key for a different operation. The server does not compare bodies: it would return the result of the first one.
A deterministic key works very well when your system already has a stable identifier:
// One key per business operation, stable across retries
$key = substr(hash('sha256', 'erp-atelier:purchase-invoice:'.$invoice->id), 0, 64);
Example retry loop
import { randomUUID } from "node:crypto";
async function postOnce(url, body, token) {
const mutationId = randomUUID(); // generated once, reused on every retry
for (let attempt = 0; attempt < 5; attempt++) {
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
"X-Client-Mutation-Id": mutationId,
},
body: JSON.stringify(body),
});
if (response.status === 429 || response.status >= 500) {
const wait = Number(response.headers.get("Retry-After") ?? 2 ** attempt);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
return {
replayed: response.headers.get("X-Idempotent-Replay") === "1",
status: response.status,
data: await response.json(),
};
} catch (networkError) {
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
}
}
throw new Error("NovaFisko API unreachable");
}
Optimistic lock with X-Base-Version
Idempotency protects against your own duplicates. The optimistic lock protects against modifications made by others: it prevents overwriting a change made between your read and your write.
Every resource tracked by the history exposes an integer version. Send the value you read in the X-Base-Version header of a PATCH, a PUT or a DELETE.
curl -X PATCH https://api.novafisko.com/v1/companies/k3Jd9fPq2LmX/third-parties/41 \
-H "Authorization: Bearer $NOVAFISKO_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Base-Version: 3" \
-d '{"email": "compta@brasserie-collines.example"}'
If the server version is still 3, the modification is applied. If someone has modified the third party in the meantime, nothing is touched and the API responds 409:
{
"message": "Cet enregistrement a été modifié entre-temps.",
"code": "conflict",
"server_version": 5,
"server_data": {
"id": 41,
"type": "supplier",
"name": "Brasserie des Collines SA",
"email": "facturation@brasserie-collines.example",
"version": 5
}
}
To resolve the conflict: compare server_data with your values, decide what to keep, then resend the request with X-Base-Version: 5.
| Point | Behaviour |
|---|---|
| Activation | Only if the header is present and numeric |
| Methods | PATCH, PUT, DELETE. Ignored on POST |
| Comparison | Conflict if the server version is strictly greater than yours |
| Target resource | The record designated by the URL (never the company itself) |
The version check comes before the idempotent replay. Combine the two headers without worry: X-Base-Version protects the data, X-Client-Mutation-Id protects the attempt.
And for offline batches
POST sync/push applies the same principles to a batch of mutations: each mutation carries its client_mutation_id and, if you wish, its base_version. A mutation that has already been processed comes back with the duplicate status and its original result. Conflicts there are merged field by field instead of being refused. See Continuous sync.