Factuarea APIDevelopers

Idempotency

Idempotency-Key header with 24h TTL. Retry POST without duplicating resources.

Write operations (POST, PATCH, DELETE) can be received multiple times if the connection drops mid-response, your integration retries after a timeout, or there are automatic retries in an intermediate gateway. To prevent the same POST from creating two invoices, the API uses the Idempotency-Key header.

Idempotency-Key is required on operations whose repetition can deliver an external, paid or irreversible effect. Those operations mark the header as required: true and document 422 idempotency_key_required in OpenAPI. It remains optional, but recommended, on reversible internal writes.

How it works

  1. The client generates a unique key per operation (a UUID v7 is the recommended choice, for consistency with the API identifiers).

  2. Send it as a header on the first request:

    POST /v1/invoices
    Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b
  3. The API stores the result (status code, headers and body) associated with that key for 24 hours.

  4. If a new request arrives with the same key within the TTL, the API returns the cached response without re-executing the handler.

The response returned on a replay includes the Idempotent-Replayed: true header so you can distinguish it.

Key format

  • An opaque string to the server: any unique value is valid (UUID v7, UUID v4, ULID, nanoid, etc.).
  • Length between 1 and 255 characters.
  • Recommendation: UUID v7 (Str::uuid7(), or any UUID v7 generator), for consistency with the API identifiers.
KEY=$(uuidgen)

curl -X POST https://api.factuarea.com/v1/invoices \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{
    "client_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a01",
    "series_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a02",
    "issued_on": "2026-05-15",
    "due_on": "2026-06-15",
    "lines": [
      { "description": "Monthly service", "quantity": 1, "unit_price": 99.00, "tax_rate_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a03" }
    ]
  }'

Automatic with the official SDKs

The TypeScript and PHP SDKs attach an Idempotency-Key to every mutation automatically and reuse the same key across the retries of one call, so a retried request never double-creates. Override it per call when you want app-level deduplication:

// auto-generated key
await factuarea.invoices.create(body);

// pin your own key (e.g. your order id)
await factuarea.invoices.create(body, { idempotencyKey: "order-4711" });
// auto-generated key
$factuarea->invoices->publicApiV1InvoicesCreate($body);

// pin your own key
$factuarea->invoices->publicApiV1InvoicesCreate($body, idempotencyKey: 'order-4711');

Payload fingerprint

The key is bound not only to the Idempotency-Key but also to a fingerprint of the request:

fingerprint = sha256(method + " " + path + "\n" + canonicalize(body))

Where canonicalize(body) is JSON with keys sorted alphabetically.

If you replay the same key with a different payload, the API responds 409 Conflict:

{
  "error": {
    "type": "idempotency_error",
    "code": "idempotency_key_reused",
    "message": "This Idempotency-Key was previously used with a different request body.",
    "request_id": "req_..."
  }
}

This is protection against bugs: no reasonable caller changes the body while keeping the same key. If you need to retry with different data — use a new key.

TTL

Entries are persisted in the idempotency_keys table for 86,400 seconds (24 h). After that they are purged by a daily schedule. If you reuse a key outside the window it's treated as a new one.

external_id vs Idempotency-Key

Both protect you from duplicates, but they solve different problems — and you can use them together.

Idempotency-Keyexternal_id
What it isA header on a single POST.A business key stored on the resource.
LifetimeEphemeral — 24h window, then purged.Durable — permanent, never expires.
ScopeDeduplicates transport retries of one call.Deduplicates by your own integration key (ERP/CRM id).
QueryableNo.YesPOST /v1/{resource}/find-by-external-id.

Use the Idempotency-Key to make a retry safe: if the network drops mid-response, replaying the same key within 24h returns the cached result instead of creating a second invoice. It is about the delivery of one request.

Use external_id to tie a Factuarea resource to a record in your own system (an order id, an ERP document number). Send it in the create body and the API enforces it is unique per company (UNIQUE(company_id, external_id)). Later you can look the resource up by that key, without storing Factuarea's id:

curl -s -X POST https://api.factuarea.com/v1/invoices/find-by-external-id \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "ORDER-4711" }' | jq '.data.id'

In short: Idempotency-Key is a short-lived retry guard; external_id is your permanent, queryable link. A typical integration sets both — a fresh key per attempt, and a stable external_id per business object.

The requirement is derived from the operation, not merely its HTTP method:

Operation classHeader contract
Email, bulk, export, upload, paid AI/third-party, tax-report or billing writesRequired
Any operation with x-irreversible: trueRequired
Reversible internal writes, such as creating or editing a draft or catalog recordOptional, recommended
Read-only POST operations such as find-by-*, calculations and previewsNot applicable
GET operationsNot applicable

Do not maintain this classification as a hard-coded list in your integration. Read the operation's Idempotency-Key.required value from the OpenAPI contract so new endpoints inherit the right behavior.

When a required operation is called without the header after its transition window closes, the API rejects it before business logic runs:

{
  "error": {
    "type": "invalid_request_error",
    "code": "idempotency_key_required",
    "param": "Idempotency-Key",
    "message": "Esta operación exige la cabecera Idempotency-Key. Envía una clave única por operación (un UUID sirve) y, si reintentas, repite exactamente la misma clave."
  }
}

During an announced transition window the same request can still be accepted and carries Deprecation: true; Sunset appears once an environment-specific cut-off has been scheduled. Test and live credentials can have different cut-offs so integrations can migrate in the sandbox first.

Stripe documents the same pattern — if you come from there, the contract is identical.

Safe retry example

import os, uuid, requests
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

def create_invoice(payload):
    key = str(uuid.uuid4())

    @retry(
        stop=stop_after_attempt(5),
        wait=wait_exponential(multiplier=1, max=10),
        retry=retry_if_exception_type(requests.exceptions.RequestException),
    )
    def attempt():
        return requests.post(
            'https://api.factuarea.com/v1/invoices',
            json=payload,
            headers={
                'Authorization': f"Bearer {os.environ['FACTUAREA_API_KEY']}",
                'Idempotency-Key': key,
            },
            timeout=30,
        )

    return attempt()
async function createInvoiceWithRetry(payload, maxAttempts = 5) {
  const key = crypto.randomUUID();
  let lastError;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const res = await fetch('https://api.factuarea.com/v1/invoices', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.FACTUAREA_API_KEY}`,
          'Idempotency-Key': key,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(payload),
      });
      if (res.ok) return res.json();
      if (res.status >= 500) {
        await new Promise(r => setTimeout(r, 2 ** attempt * 100));
        continue;
      }
      return res.json();
    } catch (err) {
      lastError = err;
      await new Promise(r => setTimeout(r, 2 ** attempt * 100));
    }
  }
  throw lastError;
}

Important: the key must remain constant across retries of the same operation. If you generate a new key on each retry you lose the protection. In the Python tenacity example, the key is generated by the outer function and captured by the retried attempt closure.

What idempotency is NOT

  • Not the same as rate limiting: an idempotent key replayed within the TTL doesn't count against your quota; but different keys with the same payload do count, one by one.
  • Not a substitute for a distributed lock on your side. If two workers concurrently create invoices with different keys, both will persist — generating the key correctly (e.g. derived from your own ID) is your responsibility.
  • Doesn't affect the server's own 4xx responses: if the first request responded 422 invalid_request_error, that 422 is cached. Replaying the key returns the same 422 with Idempotent-Replayed: true.

Store diagnostics and MCP

Store connection-test POSTs and disconnect operations declare Idempotency-Key in the REST contract. Give each intended diagnostic a key and reuse it only for retries of that diagnostic; a later fresh check needs a new key. In MCP the argument is idempotency_key, and irreversible-tool policy may require it even when discovery marks the field optional. See the MCP protocol.

On this page

Need a hand?Contact support