Aller au contenu
Documentation
Français
Ouvrir l'application

Idempotence

Rejouer une écriture sans créer de doublon avec X-Client-Mutation-Id, et protéger une modification avec le verrou optimiste X-Base-Version.

Un réseau peut couper entre l'envoi de votre requête et la réception de la réponse. Vous ne savez alors pas si la facture a été créée. L'idempotence répond à cette question : vous renvoyez la même requête avec la même clé, et NovaFisko garantit qu'elle n'est exécutée qu'une seule fois.

La clé X-Client-Mutation-Id

Ajoutez l'en-tête X-Client-Mutation-Id à toute requête d'écriture (POST, PUT, PATCH, DELETE) portant sur un dossier, c'est-à-dire sous 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"}]
  }'

Règles

Règle Détail
Format Chaîne libre de 1 à 64 caractères. Un UUID version 4 convient parfaitement
Portée La clé est unique par dossier
Mémorisation Seules les réponses réussies (statut 2xx) sont mémorisées
Requêtes concernées POST, PUT, PATCH, DELETE sous companies/{company}
Exception sync/push gère sa propre idempotence, mutation par mutation

Une clé vide ou de plus de 64 caractères est ignorée : la requête s'exécute normalement, sans protection.

Premier envoi

La requête s'exécute. Si elle réussit, NovaFisko mémorise le statut et le corps de la réponse.

HTTP/1.1 201 Created
X-Operation-Id: 5d0f6a7e-2f0b-4a35-9b56-7f1f3c1f4c22

Rejeu

Si vous renvoyez la même clé sur le même dossier, rien n'est exécuté. Vous recevez le corps mémorisé, avec le statut 200 et un en-tête qui signale le rejeu.

HTTP/1.1 200 OK
X-Idempotent-Replay: 1
Content-Type: application/json
Attention

Un rejeu répond toujours 200, même si la requête d'origine avait répondu 201 ou 204. Considérez tout statut 2xx comme un succès et vérifiez X-Idempotent-Replay si vous devez distinguer les deux cas.

Ce qui n'est pas mémorisé

Une requête qui échoue (4xx, 5xx) n'est pas mémorisée. Vous pouvez corriger les données et renvoyer la même clé, ou réessayer telle quelle après une erreur 500 ou 503.

Le corps mémorisé est limité à 500 Ko. Au-delà, le rejeu renvoie 200 avec un corps null : l'opération a bien eu lieu, relisez la ressource pour en obtenir l'état.

Bien choisir la clé

  • Générez la clé avant le premier envoi et enregistrez-la avec l'opération dans votre système.
  • Une clé désigne une intention, pas une tentative : toutes les tentatives de la même opération partagent la même clé.
  • Ne réutilisez jamais une clé pour une opération différente. Le serveur ne compare pas les corps : il renverrait le résultat de la première.

Une clé déterministe fonctionne très bien quand votre système possède déjà un identifiant stable :

// One key per business operation, stable across retries
$key = substr(hash('sha256', 'erp-atelier:purchase-invoice:'.$invoice->id), 0, 64);

Exemple de boucle de reprise

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");
}

Verrou optimiste avec X-Base-Version

L'idempotence protège contre vos propres doublons. Le verrou optimiste protège contre les modifications des autres : il empêche d'écraser un changement fait entre votre lecture et votre écriture.

Chaque ressource suivie par l'historique expose un entier version. Envoyez la valeur que vous avez lue dans l'en-tête X-Base-Version d'un PATCH, d'un PUT ou d'un 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"}'

Si la version du serveur est toujours 3, la modification s'applique. Si quelqu'un a modifié le tiers entre-temps, rien n'est touché et l'API répond 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
  }
}

Pour résoudre le conflit : comparez server_data avec vos valeurs, décidez de ce qu'il faut conserver, puis renvoyez la requête avec X-Base-Version: 5.

Point Comportement
Activation Uniquement si l'en-tête est présent et numérique
Méthodes PATCH, PUT, DELETE. Ignoré sur POST
Comparaison Conflit si la version du serveur est strictement supérieure à la vôtre
Ressource visée L'enregistrement désigné par l'URL (jamais le dossier lui-même)
Note

Le contrôle de version passe avant le rejeu idempotent. Combinez les deux en-têtes sans crainte : X-Base-Version protège la donnée, X-Client-Mutation-Id protège la tentative.

Et pour les lots hors ligne

POST sync/push applique les mêmes principes à un lot de mutations : chaque mutation porte son client_mutation_id et, si vous le souhaitez, sa base_version. Une mutation déjà traitée revient avec le statut duplicate et son résultat d'origine. Les conflits y sont fusionnés champ par champ au lieu d'être refusés. Voir Synchroniser en continu.

Voir aussi