Idempotencia
Header Idempotency-Key con TTL de 24 h. Reintenta un POST sin duplicar recursos.
Las operaciones de escritura (POST, PATCH, DELETE) pueden recibirse
varias veces si la conexión se corta a mitad de respuesta, tu integración
reintenta tras un timeout, o hay reintentos automáticos en un gateway
intermedio. Para evitar que el mismo POST cree dos facturas, la API usa el
header Idempotency-Key.
Idempotency-Key es obligatorio en las operaciones cuya repetición puede
entregar un efecto externo, de pago o irreversible. Esas operaciones marcan
el header como required: true y documentan
422 idempotency_key_required en OpenAPI. En las escrituras internas
reversibles sigue siendo opcional, aunque recomendado.
Cómo funciona
-
El cliente genera una clave única por operación (un UUID v7 es la opción recomendada, por coherencia con los identificadores de la API).
-
Envíala como header en la primera petición:
POST /v1/companies/{company}/invoices Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4bTodas las rutas de esta guía cuelgan de
/v1/companies/{company}/…:{company}es elid(UUID v7) del NIF con el que operas, y en los ejemploscurles$COMPANY_ID. Alcanzar un NIF explica cómo resolverlo. -
La API almacena el resultado (código de estado, headers y body) asociado a esa clave durante 24 horas.
-
Si llega una nueva petición con la misma clave dentro del TTL, la API devuelve la respuesta cacheada sin volver a ejecutar el handler.
La respuesta devuelta en un replay incluye el header Idempotent-Replayed: true
para que puedas distinguirla.
Formato de la clave
- Una cadena opaca para el servidor: cualquier valor único es válido (UUID v7, UUID v4, ULID, nanoid, etc.).
- Longitud entre 1 y 255 caracteres.
- Recomendación: UUID v7 (
Str::uuid7(), o cualquier generador de UUID v7), por coherencia con los identificadores de la 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ático con los SDK oficiales
Los SDK de TypeScript y PHP adjuntan un Idempotency-Key a
cada mutación automáticamente y reutilizan la misma clave en los reintentos de
una llamada, de modo que una petición reintentada nunca crea por duplicado.
Sobrescríbela por llamada cuando quieras deduplicación a nivel de aplicación:
// 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');Huella del payload
La clave queda ligada no solo al Idempotency-Key, sino también a una
huella de la petición:
fingerprint = sha256(method + " " + path + "\n" + canonicalize(body))Donde canonicalize(body) es el JSON con las claves ordenadas alfabéticamente.
Si reproduces la misma clave con un payload distinto, la API responde 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_..."
}
}Esto es una protección contra bugs: ningún caller razonable cambia el body manteniendo la misma clave. Si necesitas reintentar con datos distintos, usa una clave nueva.
TTL
Las entradas se persisten en la tabla idempotency_keys durante
86.400 segundos (24 h). Pasado ese tiempo, las purga un schedule
diario. Si reutilizas una clave fuera de la ventana, se trata como una
nueva.
external_id vs Idempotency-Key
Ambos te protegen de duplicados, pero resuelven problemas distintos — y puedes usarlos juntos.
Idempotency-Key | external_id | |
|---|---|---|
| Qué es | Un header en un único POST. | Una clave de negocio almacenada en el recurso. |
| Vida | Efímera — ventana de 24 h, luego se purga. | Duradera — permanente, nunca caduca. |
| Alcance | Deduplica reintentos de transporte de una llamada. | Deduplica por tu propia clave de integración (id de ERP/CRM). |
| Consultable | No. | Sí — POST /v1/companies/{company}/{resource}/find-by-external-id. |
Usa el Idempotency-Key para que un reintento sea seguro: si la red se
corta a mitad de respuesta, reproducir la misma clave dentro de 24 h
devuelve el resultado cacheado en lugar de crear una segunda factura. Va
sobre la entrega de una petición.
Usa external_id para vincular un recurso de Factuarea con un registro
de tu propio sistema (un id de pedido, un número de documento de ERP).
Envíalo en el body de creación y la API garantiza que es único por empresa
(UNIQUE(company_id, external_id)). Más tarde puedes localizar el recurso
por esa clave, sin almacenar el 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 resumen: Idempotency-Key es una protección de reintento de corta vida;
external_id es tu enlace permanente y consultable. Una integración típica
configura ambos — una clave nueva por intento y un external_id estable
por objeto de negocio.
Cobro desatendido: external_id es la protección de reintento
Un terminal que emite una factura simplificada ya cobrada (un POST /v1/companies/{company}/invoices con
type: F2, un bloque payment u options.register_verifactu; consulta
Facturación desde terminales desatendidos) puede tener
que reintentar días después del primer intento, cuando una clave de 24 horas ya
se purgó hace mucho. Para ese flujo, external_id hace de clave de idempotencia y
no caduca nunca. Solo se comportan así las peticiones que son cobros
desatendidos; cualquier otro POST /v1/companies/{company}/invoices conserva el upsert por
external_id descrito arriba.
Cuando el external_id ya pertenece a una factura emitida en tu empresa, la
petición es una repetición:
| Situación | Respuesta |
|---|---|
Mismo external_id, mismo tipo y total | 200 con Idempotent-Replayed: true y la factura ya emitida. Antes de responder, la API completa lo que faltaba (emitir un borrador, registrar el pago, generar el alta VeriFactu). No crea nada nuevo, no consume número y no vuelve a enviar por email una factura cuyo email ya está encolado o entregado. |
Mismo external_id, tipo o total distintos | 409 unattended_replay_mismatch, error.type: idempotency_error, subcode: unattended_replay_mismatch y param: external_id. No se cambia nada. No es el error de una Idempotency-Key reutilizada (422 idempotency_key_reused): tiene su propio código y su propio status, así que trátalo aparte. El external_id identifica UNA operación, así que usa uno nuevo para una operación nueva. |
Otra petición con el mismo external_id sigue en curso | La segunda petición espera hasta 20 segundos a la primera y después la repite. Si la primera no ha terminado, responde 409 resource_locked con param: external_id y no ha escrito nada: envía la misma petición otra vez. |
Dos peticiones simultáneas con el mismo external_id producen, por tanto, una
factura, un número y un pago.
{
"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"
}
}Obligatoria o recomendada
El requisito se deriva de la operación, no solo de su método HTTP:
| Clase de operación | Contrato del header |
|---|---|
| Escrituras de correo, lote, exportación, subida, IA/terceros de pago, declaraciones fiscales o facturación | Obligatorio |
Cualquier operación con x-irreversible: true | Obligatorio |
| Escrituras internas reversibles, como crear o editar un borrador o registro de catálogo | Opcional, recomendado |
POST de solo lectura como find-by-*, cálculos y previsualizaciones | No aplica |
Operaciones GET | No aplica |
No mantengas esta clasificación como una lista fija en tu integración. Lee el
valor Idempotency-Key.required de la operación en el
contrato OpenAPI, para que los endpoints nuevos hereden el
comportamiento correcto.
Cuando una operación obligatoria se llama sin el header después de que cierre su ventana de transición, la API la rechaza antes de ejecutar la lógica de negocio:
{
"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."
}
}Durante una ventana de transición anunciada, la misma petición todavía puede
aceptarse y lleva Deprecation: true; Sunset aparece cuando ya se ha
programado un corte específico para el entorno. Las credenciales de prueba y
producción pueden tener cortes distintos para migrar primero en el sandbox.
Stripe documenta el mismo patrón: si vienes de allí, el contrato es idéntico.
Biblioteca PDF, vistas previas y artefactos
Las operaciones de la biblioteca de plantillas PDF y de las vistas previas y artefactos PDF siguen la misma regla: la familia de la operación decide el contrato.
| Operación | Idempotency-Key | Familia |
|---|---|---|
POST /v1/companies/{company}/pdf-template-previews | Obligatoria | Exportación |
POST /v1/companies/{company}/pdf-artifacts | Obligatoria | Exportación |
POST /v1/companies/{company}/pdf-artifacts/{artifact}/render | Obligatoria | Exportación |
POST /v1/companies/{company}/pdf-templates/assets | Obligatoria | Subida |
POST y PATCH sobre /v1/companies/{company}/pdf-templates y /v1/companies/{company}/pdf-commercial-identities (crear, renombrar, revisiones, duplicar, archivar, restaurar) | Opcional, recomendada | Escritura de documento |
PATCH /v1/companies/{company}/pdf-templates/defaults y PATCH /v1/companies/{company}/pdf-templates/contact-defaults/... | Opcional, recomendada | Escritura de documento |
PATCH /v1/companies/{company}/series/{series}/pdf-template | Opcional, recomendada | Escritura de documento |
PATCH .../presentation y POST .../presentation-revisions de facturas, presupuestos, facturas proforma y albaranes, y POST /v1/companies/{company}/invoices/{invoice}/administrative-revisions | Opcional, recomendada | Escritura de documento |
Las cuatro operaciones obligatorias devuelven 422 idempotency_key_required sin la cabecera, como
cualquier otra operación obligatoria. Las lecturas no llevan clave. Recuerda que en las opcionales
un POST repetido sin clave repite su efecto.
Lo que las distingue:
- Repetición antes de las condiciones. La respuesta guardada se devuelve antes de
If-Match,expected_versiony las demás comprobaciones de versión, y antes de generar ningún identificador. Un reintento de unPATCHque ya tuvo éxito recibe el éxito original conIdempotent-Replayed: true, no un412o409provocado por tu propio primer intento. La autoridad de quien llama se verifica antes: si se revocó el rol o el acceso, recibes el error de autorización, no la respuesta guardada. - Cuerpo exacto en las tres operaciones de PDF. En
pdf-template-previews,pdf-artifactsyrender, la huella se calcula sobre el cuerpo crudo tal como lo enviaste. La misma clave con un cuerpo que difiere en un solo byte (también el orden de las claves y los espacios) devuelve422 idempotency_key_reused. Dos claves distintas con cuerpos equivalentes llevan al mismo artefacto. - Subida multipart. En
POST /v1/companies/{company}/pdf-templates/assetsla huella cubre los campos del formulario y un resumen del fichero, así que un reintento debe enviar el mismo fichero. - Una clave nueva para un intento nuevo. La respuesta guardada de la primera llamada se repite
durante 24 horas, aunque el artefacto haya cambiado de estado después. Para reintentar a
propósito un fallo reintentable, envía
renderotra vez con una clave nueva.
La misma clave sigue en ejecución
Algunas operaciones protegen la clave mientras se ejecutan, por ejemplo crear un contacto o
actualizar su perfil de cliente. Si llega una segunda petición con la misma clave antes de que
termine la primera, no se ejecuta: la API responde 409 idempotency_key_in_use
(error.type: idempotency_error) con param: Idempotency-Key, porque el resultado aún no se
conoce. Espera un momento y envía la misma petición otra vez con la misma clave: recibirás el
resultado guardado cuando la primera termine. No cambies la clave, o ejecutarías la operación dos
veces. No lo confundas con idempotency_key_reused, donde la clave ya pertenece a un cuerpo
distinto.
Ejemplo de reintento seguro
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;
}Importante: la clave debe permanecer constante en todos los reintentos del
misma operación. Si generas una clave nueva en cada reintento, pierdes la
protección. En el ejemplo de tenacity en Python, la función exterior genera
la key y el closure attempt reintentado la reutiliza.
Qué NO es la idempotencia
- No es lo mismo que el límite de peticiones: una clave idempotente reproducida dentro del TTL no cuenta contra tu cuota; pero claves distintas con el mismo payload sí cuentan, una a una.
- No sustituye a un lock distribuido por tu parte. Si dos workers crean facturas concurrentemente con claves distintas, ambas se persistirán; generar la clave correctamente (p. ej. derivada de tu propio ID) es responsabilidad tuya.
- No afecta a las respuestas
4xxpropias del servidor: si la primera petición respondió422 invalid_request_error, ese 422 se cachea. Reproducir la clave devuelve el mismo 422 conIdempotent-Replayed: true.
Diagnóstico de tiendas y MCP
Los POST de prueba de conexión y las operaciones de desconexión de tiendas declaran Idempotency-Key en REST. Asigna una clave a cada diagnóstico previsto y reutilízala solo para sus reintentos; una comprobación posterior nueva necesita otra clave. En MCP el argumento es idempotency_key y la política de herramientas irreversibles puede exigirlo aunque discovery marque el campo opcional. Consulta el protocolo MCP.
Las tools de PDF (preview_pdf_template, prepare_pdf_artifact, render_pdf_artifact y
upload_pdf_template_asset) y las tools de escritura de la biblioteca aceptan el mismo argumento
idempotency_key y lo repiten antes de la comprobación de versión, igual que las operaciones REST.