Factuarea API
Conceptos clave

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

  1. 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).

  2. Envíala como header en la primera petición:

    POST /v1/invoices
    Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b
  3. La API almacena el resultado (código de estado, headers y body) asociado a esa clave durante 24 horas.

  4. 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-Keyexternal_id
Qué esUn header en un único POST.Una clave de negocio almacenada en el recurso.
VidaEfímera — ventana de 24 h, luego se purga.Duradera — permanente, nunca caduca.
AlcanceDeduplica reintentos de transporte de una llamada.Deduplica por tu propia clave de integración (id de ERP/CRM).
ConsultableNo.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

EndpointIdempotencia recomendada
POST /v1/invoices (crítica)
POST /v1/quotes
POST /v1/clients
POST /v1/invoices/{id}/send
POST /v1/invoices/{id}/mark-paid
POST /v1/invoices/{id}/payments (un pago reintentado se contaría dos veces)
POST /v1/purchase_invoices/{id}/payments (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 4xx propias del servidor: si la primera petición respondió 422 invalid_request_error, ese 422 se cachea. Reproducir la clave devuelve el mismo 422 con Idempotent-Replayed: true.

En esta página