Factuarea APIDevelopers

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

  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/companies/{company}/invoices
    Idempotency-Key: 01928f10-7c0e-7c4a-9b7d-2f8a6e3c1d4b

    Todas las rutas de esta guía cuelgan de /v1/companies/{company}/…: {company} es el id (UUID v7) del NIF con el que operas, y en los ejemplos curl es $COMPANY_ID. Alcanzar un NIF explica cómo resolverlo.

  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 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-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.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ónRespuesta
Mismo external_id, mismo tipo y total200 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 distintos409 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 cursoLa 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ónContrato del header
Escrituras de correo, lote, exportación, subida, IA/terceros de pago, declaraciones fiscales o facturaciónObligatorio
Cualquier operación con x-irreversible: trueObligatorio
Escrituras internas reversibles, como crear o editar un borrador o registro de catálogoOpcional, recomendado
POST de solo lectura como find-by-*, cálculos y previsualizacionesNo aplica
Operaciones GETNo 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ónIdempotency-KeyFamilia
POST /v1/companies/{company}/pdf-template-previewsObligatoriaExportación
POST /v1/companies/{company}/pdf-artifactsObligatoriaExportación
POST /v1/companies/{company}/pdf-artifacts/{artifact}/renderObligatoriaExportación
POST /v1/companies/{company}/pdf-templates/assetsObligatoriaSubida
POST y PATCH sobre /v1/companies/{company}/pdf-templates y /v1/companies/{company}/pdf-commercial-identities (crear, renombrar, revisiones, duplicar, archivar, restaurar)Opcional, recomendadaEscritura de documento
PATCH /v1/companies/{company}/pdf-templates/defaults y PATCH /v1/companies/{company}/pdf-templates/contact-defaults/...Opcional, recomendadaEscritura de documento
PATCH /v1/companies/{company}/series/{series}/pdf-templateOpcional, recomendadaEscritura de documento
PATCH .../presentation y POST .../presentation-revisions de facturas, presupuestos, facturas proforma y albaranes, y POST /v1/companies/{company}/invoices/{invoice}/administrative-revisionsOpcional, recomendadaEscritura 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_version y las demás comprobaciones de versión, y antes de generar ningún identificador. Un reintento de un PATCH que ya tuvo éxito recibe el éxito original con Idempotent-Replayed: true, no un 412 o 409 provocado 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-artifacts y render, 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) devuelve 422 idempotency_key_reused. Dos claves distintas con cuerpos equivalentes llevan al mismo artefacto.
  • Subida multipart. En POST /v1/companies/{company}/pdf-templates/assets la 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 render otra 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 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.

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.

En esta página

¿Te echamos una mano?Contactar con soporte