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/invoices Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b -
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/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 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_..."
}
}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/{recurso}/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/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.
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.
Ejemplo de reintento seguro
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;
}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.