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
admite el header Idempotency-Key.
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 64 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.
Recomendaciones por endpoint
| Endpoint | Idempotencia recomendada |
|---|---|
POST /v1/invoices | Sí (crítica) |
POST /v1/quotes | Sí |
POST /v1/clients | Sí |
POST /v1/invoices/{id}/send | Sí |
POST /v1/invoices/{id}/mark-paid | Sí |
POST /v1/invoices/{id}/payments | Sí (un pago reintentado se contaría dos veces) |
POST /v1/purchase_invoices/{id}/payments | Sí (un pago reintentado se contaría dos veces) |
GET /v1/... | N/A (sin efecto) |
PATCH /v1/... | Opcional (PATCH es idempotente por definición) |
DELETE /v1/... | Opcional |
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
@retry(
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=1, max=10),
retry=retry_if_exception_type(requests.exceptions.RequestException),
)
def create_invoice(payload):
key = str(uuid.uuid4())
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,
)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
mismo POST. Si generas una clave nueva en cada reintento, pierdes la
protección. En el ejemplo de tenacity en Python, la key se genera
fuera del closure y se reutiliza en todos los reintentos.
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.