Skip to content
Documentation
English
Open the app

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
Warning

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)
Note

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.

See also