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/companies/{company}/invoices
    Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b

    All the routes in this guide hang off /v1/companies/{company}/…: {company} is the id (UUID v7) of the tax ID you operate on, and in the curl examples it is $COMPANY_ID. Reach a tax ID explains how to resolve it.

  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/companies/$COMPANY_ID/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 422 Unprocessable Entity:

{
  "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.Yes — POST /v1/companies/{company}/{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/companies/$COMPANY_ID/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.

Unattended checkout: external_id is the retry guard

A terminal that issues a paid simplified invoice (a POST /v1/companies/{company}/invoices with type: F2, a payment block or options.register_verifactu; see Unattended checkout invoicing) may need to retry days after the first attempt, long after a 24-hour key has been purged. For that flow, external_id does the job of an idempotency key and never expires. Only requests that are checkouts behave this way; any other POST /v1/companies/{company}/invoices keeps the upsert by external_id described above.

When the external_id already belongs to an invoice issued in your company, the request is a replay:

SituationAnswer
Same external_id, same type and total200 with Idempotent-Replayed: true and the invoice already issued. Before answering, the API completes what was missing (issue a draft, register the payment, generate the VeriFactu alta). It creates nothing new, consumes no number and does not email again an invoice whose email is already queued or delivered.
Same external_id, different type or total409 unattended_replay_mismatch, error.type: idempotency_error, subcode: unattended_replay_mismatch and param: external_id. Nothing is changed. It is not the error of a reused Idempotency-Key (422 idempotency_key_reused): it has its own code and status, so handle it on its own. The external_id identifies ONE operation, so use a new one for a new operation.
Another request with the same external_id is still runningThe second request waits up to 20 seconds for the first and then replays it. If the first has not finished, it answers 409 resource_locked with param: external_id and has written nothing: send the same request again.

Two simultaneous requests with the same external_id therefore produce one invoice, one number and one payment.

{
  "error": {
    "type": "idempotency_error",
    "code": "unattended_replay_mismatch",
    "subcode": "unattended_replay_mismatch",
    "message": "La operación con external_id «KIOSK-0042-20260601-000187» ya está emitida con un tipo o un total distintos y no puede reenviarse con datos diferentes.",
    "param": "external_id",
    "doc_url": "https://docs.factuarea.com/guides/errors#unattended_replay_mismatch",
    "request_id": "req_01J8Y3M2Q4CF0000000000URM1"
  }
}

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.

PDF library, previews and artifacts

The operations of the PDF template library and of PDF previews and artifacts follow the same rule: the family of the operation decides the contract.

OperationIdempotency-KeyFamily
POST /v1/companies/{company}/pdf-template-previewsRequiredExport
POST /v1/companies/{company}/pdf-artifactsRequiredExport
POST /v1/companies/{company}/pdf-artifacts/{artifact}/renderRequiredExport
POST /v1/companies/{company}/pdf-templates/assetsRequiredUpload
POST and PATCH on /v1/companies/{company}/pdf-templates and /v1/companies/{company}/pdf-commercial-identities (create, rename, revisions, duplicate, archive, restore)Optional, recommendedDocument write
PATCH /v1/companies/{company}/pdf-templates/defaults and PATCH /v1/companies/{company}/pdf-templates/contact-defaults/...Optional, recommendedDocument write
PATCH /v1/companies/{company}/series/{series}/pdf-templateOptional, recommendedDocument write
PATCH .../presentation and POST .../presentation-revisions of invoices, quotes, proformas and delivery notes, and POST /v1/companies/{company}/invoices/{invoice}/administrative-revisionsOptional, recommendedDocument write

The four required operations return 422 idempotency_key_required without the header, like any other required operation. Reads do not take the key. Remember that for the optional ones a repeated POST without a key repeats its effect.

What is different about them:

  • Replay before conditions. The stored response is returned before If-Match, expected_version and the other version checks, and before any identifier is generated. A retry of a PATCH that already succeeded gets the original success with Idempotent-Replayed: true, not a 412 or 409 caused by your own first attempt. The caller's authority is verified first: if the role or the access was revoked, you get the authorization error, not the cached response.
  • Exact body for the three PDF operations. For pdf-template-previews, pdf-artifacts and render, the fingerprint is computed over the raw body as you sent it. The same key with a body that differs by a single byte (key order and whitespace included) returns 422 idempotency_key_reused. Two different keys with equivalent bodies lead to the same artifact.
  • Multipart upload. For POST /v1/companies/{company}/pdf-templates/assets the fingerprint covers the form fields and a digest of the file, so a retry must send the same file.
  • A new key for a new attempt. The stored answer of the first call is replayed for 24 hours, even if the artifact changed state afterwards. To retry a retryable failure on purpose, send render again with a new key.

The same key is still running

Some operations protect the key while they are executing, for example creating a contact or updating its customer profile. If a second request with the same key arrives before the first one finished, it does not run: the API answers 409 idempotency_key_in_use (error.type: idempotency_error) with param: Idempotency-Key, because the result is not known yet. Wait a moment and send the same request again with the same key: you receive the stored result once the first one completes. Do not change the key, or you would run the operation twice. Do not confuse it with idempotency_key_reused, where the key already belongs to a different body.

Safe retry example

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

COMPANY_ID = os.environ['COMPANY_ID']

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(
            f'https://api.factuarea.com/v1/companies/{COMPANY_ID}/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();
  const COMPANY_ID = process.env.COMPANY_ID;
  let lastError;
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const res = await fetch(`https://api.factuarea.com/v1/companies/${COMPANY_ID}/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.

The PDF tools (preview_pdf_template, prepare_pdf_artifact, render_pdf_artifact and upload_pdf_template_asset) and the write tools of the library take the same idempotency_key argument, and replay it before the version check just like the REST operations.

On this page

Need a hand?Contact support