Idempotència
Header Idempotency-Key amb TTL de 24 h. Reintenta un POST sense duplicar recursos.
Les operacions d'escriptura (POST, PATCH, DELETE) es poden rebre
diverses vegades si la connexió es talla a mitja resposta, la teva integració
reintenta després d'un timeout, o hi ha reintents automàtics en un gateway
intermedi. Per evitar que el mateix POST creï dues factures, l'API fa servir el
header Idempotency-Key.
Idempotency-Key és obligatori a les operacions la repetició de les quals
pot lliurar un efecte extern, de pagament o irreversible. Aquestes operacions
marquen el header com a required: true i documenten
422 idempotency_key_required a OpenAPI. A les escriptures internes
reversibles continua sent opcional, tot i que recomanat.
Com funciona
-
El client genera una clau única per operació (un UUID v7 és l'opció recomanada, per coherència amb els identificadors de l'API).
-
Envia-la com a header en la primera petició:
POST /v1/companies/{company}/invoices Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4bTotes les rutes d'aquesta guia pengen de
/v1/companies/{company}/…:{company}és l'id(UUID v7) del NIF amb què operes, i als exemplescurlés$COMPANY_ID. Arribar a un NIF explica com resoldre'l. -
L'API emmagatzema el resultat (codi d'estat, headers i body) associat a aquesta clau durant 24 hores.
-
Si arriba una nova petició amb la mateixa clau dins del TTL, l'API retorna la resposta a la cau sense tornar a executar el handler.
La resposta retornada en un replay inclou el header Idempotent-Replayed: true
perquè la puguis distingir.
Format de la clau
- Una cadena opaca per al servidor: qualsevol valor únic és vàlid (UUID v7, UUID v4, ULID, nanoid, etc.).
- Longitud entre 1 i 255 caràcters.
- Recomanació: UUID v7 (
Str::uuid7(), o qualsevol generador d'UUID v7), per coherència amb els identificadors de l'API.
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" }
]
}'Automàtic amb els SDK oficials
Els SDK de TypeScript i PHP adjunten un Idempotency-Key a
cada mutació automàticament i reutilitzen la mateixa clau en els reintents d'una
crida, de manera que una petició reintentada mai no crea per duplicat.
Sobreescriu-la per crida quan vulguis deduplicació a nivell d'aplicació:
// 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');Empremta del payload
La clau queda lligada no només a l'Idempotency-Key, sinó també a una
empremta de la petició:
fingerprint = sha256(method + " " + path + "\n" + canonicalize(body))On canonicalize(body) és el JSON amb les claus ordenades alfabèticament.
Si reprodueixes la mateixa clau amb un payload diferent, l'API respon 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_..."
}
}Això és una protecció contra bugs: cap caller raonable canvia el body mantenint la mateixa clau. Si necessites reintentar amb dades diferents, fes servir una clau nova.
TTL
Les entrades es persisteixen a la taula idempotency_keys durant
86.400 segons (24 h). Després d'això, les purga un schedule
diari. Si reutilitzes una clau fora de la finestra, es tracta com una
de nova.
external_id vs Idempotency-Key
Tots dos et protegeixen de duplicats, però resolen problemes diferents — i els pots fer servir junts.
Idempotency-Key | external_id | |
|---|---|---|
| Què és | Un header en un únic POST. | Una clau de negoci emmagatzemada al recurs. |
| Vida | Efímera — finestra de 24 h, després es purga. | Duradora — permanent, mai no caduca. |
| Abast | Deduplica reintents de transport d'una crida. | Deduplica per la teva pròpia clau d'integració (id d'ERP/CRM). |
| Consultable | No. | Sí — POST /v1/companies/{company}/{resource}/find-by-external-id. |
Fes servir l'Idempotency-Key perquè un reintent sigui segur: si la
xarxa es talla a mitja resposta, reproduir la mateixa clau dins de 24 h
retorna el resultat a la cau en lloc de crear una segona factura. Va sobre
el lliurament d'una petició.
Fes servir external_id per vincular un recurs de Factuarea amb un
registre del teu propi sistema (un id de comanda, un número de document
d'ERP). Envia'l al body de creació i l'API garanteix que és únic per empresa
(UNIQUE(company_id, external_id)). Més tard pots localitzar el recurs per
aquesta clau, sense emmagatzemar l'id de Factuarea:
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'En resum: Idempotency-Key és una protecció de reintent de vida curta;
external_id és el teu enllaç permanent i consultable. Una integració
típica configura tots dos — una clau nova per intent i un external_id
estable per objecte de negoci.
Cobrament desatès: external_id és la protecció de reintent
Un terminal que emet una factura simplificada ja cobrada (un POST /v1/companies/{company}/invoices amb
type: F2, un bloc payment o options.register_verifactu; consulta
Facturació des de terminals desatesos) pot haver de
reintentar dies després del primer intent, quan una clau de 24 hores ja es va
purgar fa temps. Per a aquest flux, external_id fa de clau d'idempotència i no
caduca mai. Només es comporten així les peticions que són cobraments desatesos;
qualsevol altre POST /v1/companies/{company}/invoices conserva l'upsert per external_id descrit a
dalt.
Quan l'external_id ja pertany a una factura emesa a la teva empresa, la petició és
una repetició:
| Situació | Resposta |
|---|---|
Mateix external_id, mateix tipus i total | 200 amb Idempotent-Replayed: true i la factura ja emesa. Abans de respondre, l'API completa el que faltava (emetre un esborrany, registrar el pagament, generar l'alta VeriFactu). No crea res de nou, no consumeix número i no torna a enviar per email una factura l'email de la qual ja és a la cua o lliurat. |
Mateix external_id, tipus o total diferents | 409 unattended_replay_mismatch, error.type: idempotency_error, subcode: unattended_replay_mismatch i param: external_id. No es canvia res. No és l'error d'una Idempotency-Key reutilitzada (422 idempotency_key_reused): té el seu propi codi i el seu propi status, així que tracta'l a part. L'external_id identifica UNA operació, així que fes-ne servir un de nou per a una operació nova. |
Una altra petició amb el mateix external_id continua en curs | La segona petició espera fins a 20 segons la primera i després la repeteix. Si la primera no ha acabat, respon 409 resource_locked amb param: external_id i no ha escrit res: envia la mateixa petició una altra vegada. |
Dues peticions simultànies amb el mateix external_id produeixen, per tant, una
factura, un número i un pagament.
{
"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"
}
}Obligatòria o recomanada
El requisit es deriva de l'operació, no només del seu mètode HTTP:
| Classe d'operació | Contracte del header |
|---|---|
| Escriptures de correu, lot, exportació, pujada, IA/tercers de pagament, declaracions fiscals o facturació | Obligatori |
Qualsevol operació amb x-irreversible: true | Obligatori |
| Escriptures internes reversibles, com crear o editar un esborrany o registre de catàleg | Opcional, recomanat |
POST de només lectura com find-by-*, càlculs i previsualitzacions | No s'aplica |
Operacions GET | No s'aplica |
No mantinguis aquesta classificació com una llista fixa a la teva integració.
Llegeix el valor Idempotency-Key.required de l'operació al
contracte OpenAPI, perquè els endpoints nous heretin el
comportament correcte.
Quan una operació obligatòria es crida sense el header després que es tanqui la seva finestra de transició, l'API la rebutja abans d'executar la lògica de negoci:
{
"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."
}
}Durant una finestra de transició anunciada, la mateixa petició encara es pot
acceptar i porta Deprecation: true; Sunset apareix quan ja s'ha programat
un tall específic per a l'entorn. Les credencials de prova i producció poden
tenir talls diferents per migrar primer al sandbox.
Stripe documenta el mateix patró: si vens d'allà, el contracte és idèntic.
Biblioteca PDF, vistes prèvies i artefactes
Les operacions de la biblioteca de plantilles PDF i de les vistes prèvies i artefactes PDF segueixen la mateixa regla: la família de l’operació decideix el contracte.
| Operació | Idempotency-Key | Família |
|---|---|---|
POST /v1/companies/{company}/pdf-template-previews | Obligatòria | Exportació |
POST /v1/companies/{company}/pdf-artifacts | Obligatòria | Exportació |
POST /v1/companies/{company}/pdf-artifacts/{artifact}/render | Obligatòria | Exportació |
POST /v1/companies/{company}/pdf-templates/assets | Obligatòria | Pujada |
POST i PATCH sobre /v1/companies/{company}/pdf-templates i /v1/companies/{company}/pdf-commercial-identities (crear, canviar el nom, revisions, duplicar, arxivar, restaurar) | Opcional, recomanada | Escriptura de document |
PATCH /v1/companies/{company}/pdf-templates/defaults i PATCH /v1/companies/{company}/pdf-templates/contact-defaults/... | Opcional, recomanada | Escriptura de document |
PATCH /v1/companies/{company}/series/{series}/pdf-template | Opcional, recomanada | Escriptura de document |
PATCH .../presentation i POST .../presentation-revisions de factures, pressupostos, factures proforma i albarans, i POST /v1/companies/{company}/invoices/{invoice}/administrative-revisions | Opcional, recomanada | Escriptura de document |
Les quatre operacions obligatòries retornen 422 idempotency_key_required sense la capçalera, com
qualsevol altra operació obligatòria. Les lectures no porten clau. Recorda que a les opcionals un
POST repetit sense clau repeteix el seu efecte.
El que les distingeix:
- Repetició abans de les condicions. La resposta desada es retorna abans d’
If-Match,expected_versioni les altres comprovacions de versió, i abans de generar cap identificador. Un reintent d’unPATCHque ja ha reeixit rep l’èxit original ambIdempotent-Replayed: true, no un412o409provocat pel teu propi primer intent. L’autoritat de qui crida es verifica abans: si s’ha revocat el rol o l’accés, reps l’error d’autorització, no la resposta desada. - Cos exacte a les tres operacions de PDF. A
pdf-template-previews,pdf-artifactsirender, l’empremta es calcula sobre el cos cru tal com el vas enviar. La mateixa clau amb un cos que difereix en un sol byte (també l’ordre de les claus i els espais) retorna422 idempotency_key_reused. Dues claus diferents amb cossos equivalents porten al mateix artefacte. - Pujada multipart. A
POST /v1/companies/{company}/pdf-templates/assetsl’empremta cobreix els camps del formulari i un resum del fitxer, així que un reintent ha d’enviar el mateix fitxer. - Una clau nova per a un intent nou. La resposta desada de la primera crida es repeteix durant
24 hores, encara que l’artefacte hagi canviat d’estat després. Per reintentar a consciència un
error reintentable, envia
renderuna altra vegada amb una clau nova.
La mateixa clau continua en execució
Algunes operacions protegeixen la clau mentre s’executen, per exemple crear un contacte o
actualitzar-ne el perfil de client. Si arriba una segona petició amb la mateixa clau abans que
acabi la primera, no s’executa: l’API respon 409 idempotency_key_in_use
(error.type: idempotency_error) amb param: Idempotency-Key, perquè el resultat encara no es
coneix. Espera un moment i envia la mateixa petició una altra vegada amb la mateixa clau:
rebràs el resultat desat quan la primera acabi. No canviïs la clau, o executaries l’operació dues
vegades. No ho confonguis amb idempotency_key_reused, on la clau ja pertany a un cos diferent.
Exemple de reintent segur
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: la clau ha de romandre constant en tots els reintents del
mateixa operació. Si generes una clau nova en cada reintent, perds la
protecció. En l'exemple de tenacity en Python, la funció exterior genera la
key i el closure attempt reintentat la reutilitza.
Què NO és la idempotència
- No és el mateix que el límit de peticions: una clau idempotent reproduïda dins del TTL no compta contra la teva quota; però claus diferents amb el mateix payload sí que compten, una a una.
- No substitueix un lock distribuït per la teva banda. Si dos workers creen factures de manera concurrent amb claus diferents, totes dues es persistiran; generar la clau correctament (p. ex. derivada del teu propi ID) és responsabilitat teva.
- No afecta les respostes
4xxpròpies del servidor: si la primera petició va respondre422 invalid_request_error, aquest 422 es desa a la cau. Reproduir la clau retorna el mateix 422 ambIdempotent-Replayed: true.
Diagnòstic de botigues i MCP
Els POST de prova de connexió i les operacions de desconnexió de botigues declaren Idempotency-Key a REST. Assigna una clau a cada diagnòstic previst i reutilitza-la només per als seus reintents; una comprovació posterior nova necessita una altra clau. A MCP l’argument és idempotency_key i la política d’eines irreversibles pot exigir-lo encara que discovery marqui el camp opcional. Consulta el protocol MCP.
Les tools de PDF (preview_pdf_template, prepare_pdf_artifact, render_pdf_artifact i
upload_pdf_template_asset) i les tools d’escriptura de la biblioteca accepten el mateix argument
idempotency_key i el repeteixen abans de la comprovació de versió, igual que les operacions REST.