New France e-invoicing mandate goes live September 2026 — our implementation is ready. See all mandates →
Guides

Idempotency keys

Every POST request across every Clearvo product must include an x-idempotency-key header containing a UUID v4, with one exception: on POST /v1/send the header is optional (see below). This protects against duplicate submissions caused by network timeouts or retries — the example below uses POST /send, but the same mechanism applies to every write endpoint.

Guides

How it works

  • On the first request with a given key, Clearvo processes the request and caches the response.
  • On any subsequent request with the same key and identical payload, the cached response is returned immediately — nothing is re-processed or re-submitted to an authority.
  • If you submit a different payload with the same key, Clearvo returns HTTP 409 Conflict on the endpoints that require the header. POST /send is the exception, see the next point. Generate a new UUID for each new request.
  • POST /v1/send works differently, and the header is optional there. Every document gets a system-generated identity key built from its own fields (documentType + country + invoiceNumber; for a purchase also the supplier tax ID and issue date; if you send one order line per call, the line's lineNumber is part of it too, so lines 1 and 2 of the same order are separate documents), returned in the X-Idempotency-Key response header. That key alone decides whether two requests are the same document, so re-sending an invoice returns the stored result whatever header you send. Re-sending the same invoice number with different content returns 409 IDEMPOTENT_BODY_MISMATCH (the document has already been processed), whatever x-idempotency-key you send; an invoice that was rejected, needs information, or is held on a tax code is re-run instead. A key you supply is only your retry hint and never blocks a document: reusing it for a different document is processed under that document's own identity, and the response carries the warning IDEMPOTENCY_KEY_REUSED_ACROSS_DOCUMENTS in warnings[]. A document missing one of those identity fields is no longer refused: it is stored under an identity Clearvo mints (your key, when you send one, is used instead) and the response carries the warning NO_IDENTITY_NO_DEDUP, because a re-send of it cannot be matched. The refused edit of an already processed document (409 IDEMPOTENT_BODY_MISMATCH) is recorded in the stored document's history. Every other write endpoint still requires the header.
  • Keys expire after 24 hours on Starter, 72 hours on Growth, and configurable on Enterprise.

Best practice

1
Generate the key before the request

Use crypto.randomUUID() (Node 19+) or a UUID library. Store it alongside your pending record so you can replay it on retry.

2
Retry on network failure

If the request times out or you receive a 5xx, retry with the same idempotency key. You will get the cached 200 response if Clearvo already processed the request. On POST /send, a 503 INGEST_UNAVAILABLE means nothing was stored, so retry with the same body. A 503 with Retry-After (including a temporary failure before storage) means retry the same request with the same key. When enabled for your account, a 200 with status: RECEIVED and a PROCESSING_DELAYED warning means the document is stored: do not resend it, Clearvo finishes it.

3
Never reuse a key for a different request

Each unique operation (e.g. a different invoiceNumber, or any field change) must use a fresh UUID.

JavaScript — Idempotent submit with retry
async function submitWithRetry(payload, idempotencyKey) {
  const MAX_RETRIES = 3;

  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    try {
      const res = await fetch('https://api.clearvo.io/v1/send', {
        method: 'POST',
        headers: {
          'x-api-key': process.env.CLEARVO_API_KEY,
          'x-idempotency-key': idempotencyKey, // same key on every retry
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });

      if (res.status === 409) throw new Error('Key reused with different payload');
      if (res.ok) return res.json();
      if (res.status < 500) throw new Error(`Client error: ${res.status}`);
    } catch (err) {
      if (attempt === MAX_RETRIES - 1) throw err;
      await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
    }
  }
}