Guía · Técnica

Idempotencia en una API Verifactu: por qué una clave que caduca no basta

Actualizado el · Equipo técnico de FactuBridge

Toda integración por red tiene un momento incómodo: envías una petición, el servidor la procesa… y la respuesta no llega. Timeout, corte de conexión, reinicio a destiempo. Tu ERP no sabe si la factura se registró o no. En un sistema Verifactu ese momento es especialmente delicado, porque lo que se creó al otro lado no es una fila cualquiera: es unregistro de facturación encadenado por huella y remitido a la AEAT. No se puede «borrar el duplicado y ya».

La respuesta clásica de la industria es la idempotencia: que reintentar la misma operación produzca el mismo resultado, no un resultado nuevo. Esta guía explica cómo suele implementarse, qué huecos deja la implementación habitual y cómo lo resuelve FactuBridge.

El patrón habitual: una caché de respuestas con caducidad

La mayoría de APIs — incluidas otras APIs Verifactu del mercado — implementan la idempotencia como una caché de respuestas: el cliente envía una cabecera Idempotency-Key con un valor único, el servidor guarda la respuesta asociada a esa clave y, si la misma clave vuelve a llegar, devuelve la respuesta guardada. La clave se recuerda durante una ventana de tiempo — típicamente 24 horas — y después se olvida.

Es un patrón razonable y muy extendido (lo popularizaron las APIs de pagos). Pero conviene entender lo que implica esa palabra: caché.

El enfoque de FactuBridge: idempotencia estructural

En FactuBridge la idempotencia no vive en una caché al lado de los datos:vive en los propios datos, y por eso no caduca.

/create: la factura es su propia clave

Para crear registros no necesitas ninguna cabecera. Una factura ya tiene identidad natural — serie, número y fecha de expedición dentro de tu SIF — y esa identidad es la clave de idempotencia,permanente por construcción. Un reintento, llegue a los 30 segundos o a los 30 días, encuentra el registro existente:

POST /v1/create
Authorization: Bearer $FACTUBRIDGE_API_KEY

{ "serie": "A", "numero": "2026-0417", "fecha_expedicion": "08-07-2026", ... }

# La respuesta se pierde (timeout). Tu ERP reintenta la misma llamada:

# → 409 Conflict
#   {
#     "detail": "Factura ya registrada",
#     "registro": {
#       "uuid": "9b2e7c1a-...",
#       "estado": "Correcto",
#       "url": "https://www2.agenciatributaria.gob.es/...",
#       "huella": "3AD5CA54A0DE383B..."
#     }
#   }
# Es seguro tratarlo como éxito: adopta el registro y sigue.

No hay ventana que vigilar ni clave que generar: es imposible duplicar un alta por reintento, aunque el código del cliente no haya previsto nada. La seguridad que no depende de que el integrador se acuerde de algo es la que de verdad funciona un viernes a las 19:00.

Sobre esa red de seguridad, /create admite además la misma cabecera opcional Idempotency-Key que /modify y/cancel. No la sustituye — la clave natural sigue protegiendo aunque no envíes nada —, sino que hace el reintento aún más simple: con clave, el replay es un 200 con el estado actual del registro (cabecera Idempotent-Replayed: true), sin lógica de «interpretar el 409 como éxito» en tu código. Y aporta algo más sutil: cuando envías una clave determinista (cómo elegirla, más abajo), un 409 deja de ser ambiguo — ya nunca es un reintento. Significa una de dos cosas: una colisión real de numeración — dos facturas distintas disputándose la misma serie y número — o el alta repetida de una factura cuyo registro anulaste, un flujo legítimo que va por otra puerta: la AEAT solo admite reactivar un registro anulado como alta de subsanación (Subsanacion=S), y en FactuBridge esa vía es /modify — un alta normal la rechazaría también la propia Agencia. Ambos casos merecen alerta, no adopción silenciosa.

POST /v1/create
Authorization: Bearer $FACTUBRIDGE_API_KEY
Idempotency-Key: crea-84213

{ "serie": "A", "numero": "2026-0417", "fecha_expedicion": "08-07-2026", ... }

# La respuesta se pierde (timeout). Reintento con la MISMA clave:

# → 200 OK   (Idempotent-Replayed: true)
#   { "uuid": "9b2e7c1a-...", "estado": "Correcto", "huella": "..." }
#
# Mismo uuid, estado ACTUAL — sin lógica de adopción del 409.
# Y si algún día recibes un 409 enviando clave, ya no es un
# reintento: o colisión real de numeración, o el alta de una
# factura anulada (que se reactiva por /modify). Merece alerta.

/create_bulk admite la misma cabecera, con un matiz de ámbito: la clave protege el lote completo, no facturas sueltas. El replay devuelve el array de respuestas íntegro, en el orden original y con el estado actual de cada registro — si la AEAT ya contestó a parte del lote entre el intento perdido y el reintento, lo ves en esa misma respuesta. Y como el lote es atómico — todo o nada —, el reintento sin cabecera también sigue siendo seguro: se apoya en el 409 por clave natural de la primera factura, adoptable como éxito igual que en el ejemplo sin cabecera.

/modify y /cancel: la clave se persiste con el registro

Subsanar y anular son distintas: repetir una subsanación puede ser perfectamente legítimo (corriges dos veces la misma factura), así que aquí el servidor no puede adivinar si tu segunda llamada es un reintento o una corrección nueva. Para eso está la cabecera opcionalIdempotency-Key — con una diferencia de fondo respecto al patrón caché: la clave se guarda en el propio registro de facturación, protegida por un índice único por SIF.

PUT /v1/modify
Authorization: Bearer $FACTUBRIDGE_API_KEY
Idempotency-Key: sub-84213

{ "serie": "A", "numero": "2026-0417", ... }   # subsanación

# Primera llamada (la respuesta se pierde por un corte de red):
# → { "uuid": "4f1a...", "estado": "Pendiente", ... }

# Reintento con la MISMA clave, minutos u horas después:
# → 200 OK
#   { "uuid": "4f1a...", "estado": "Correcto", "huella": "..." }
#
# Mismo uuid — no se crea un segundo registro — y el estado
# devuelto es el ACTUAL: si la AEAT ya respondió, lo ves aquí.

Tres propiedades se siguen de ese diseño:

El contrato de la cabecera: misma clave, mismo cuerpo

En los cuatro endpoints la cabecera funciona igual, y el contrato tiene una segunda mitad que conviene conocer: el replay exige misma clave y mismo cuerpo. Junto a la clave se persiste un hash SHA-256 del cuerpo de la petición, y un reintento solo se reconoce como tal si ambos coinciden. La misma clave con un cuerpo distinto no devuelve nada silenciosamente parecido: responde422 idempotency_key_reused_with_different_payload sin crear ni modificar registros — la señal inequívoca de que estás reutilizando una clave que pertenece a otro intento lógico. Dos detalles más del contrato: el formato de la clave se valida a la entrada (ASCII imprimible, 1 a 80 caracteres; si no cumple, 400 invalid_idempotency_key), y cuando envías clave y no hay replay la respuesta también lo dice, conIdempotent-Replayed: false — nunca te quedas sin saber si el servidor reconoció tu reintento.

Cómo elegir la clave: determinista, no aleatoria

El error más común con Idempotency-Key es generar un UUID nuevo en cada intento: cada reintento lleva una clave distinta y la protección se anula a sí misma. La clave debe serestable entre reintentos del mismo intento lógico y distinta entre intentos lógicos distintos. La receta que usamos en las integraciones que mantenemos: derivarla del estado local que motiva la operación, con un prefijo por vía de entrada.

OperaciónClave recomendadaPor qué es estable
Altacrea-{id local de la factura}El id de la factura se persiste en tu ERP antes del envío y sobrevive a rollbacks posteriores: cada reintento parte del mismo id → misma clave. Una factura nueva tiene otro id → clave nueva.
Lotelote-{id local de la tanda}La clave protege el lote completo, así que se deriva del identificador de la tanda (la migración, el cierre diario…), no de las facturas que contiene. Un reintento reenvía la misma tanda → misma clave; una tanda nueva → clave nueva. Y una tanda corregida tras un fallo de validación también es una tanda nueva: cuerpo distinto → clave distinta.
Subsanaciónsub-{id del registro previo}Tras un rollback local, el registro previo sigue siendo el mismo → misma clave. Cuando la subsanación por fin persiste, el siguiente intento lógico parte de otro registro → clave nueva. Ojo: si la AEAT la rechaza y la reenvías corregida, eso no es un reintento sino un intento nuevo — deriva otra clave (por ejemplo, sufijando el número de intento: sub-84213-2).
Anulaciónanu-{id del alta vigente}Anular la misma factura dos veces con éxito es imposible; la clave solo se repite en reintentos reales.

Regla mnemotécnica: si tu clave sobrevive a un rollback de tu transacción local, está bien elegida. Si la generas conuuid4() dentro del intento, no. Y la regla complementaria: si cambias el cuerpo — típicamente para corregir un rechazo de la AEAT —, cambia también la clave. Cuerpo nuevo, intento nuevo.

Lo que la permanencia te pide a cambio

Toda decisión de diseño tiene un precio, y el de la permanencia es este: tu clave debe ser única para siempre dentro de tu SIF, no solo durante una ventana de 24 horas. Un patrón caché «perdona» con el tiempo una clave mal derivada; FactuBridge no la olvida. Conviene ver qué implica eso en la práctica — y, sobre todo, qué no:

Resumen comparativo

Patrón caché con TTLFactuBridge
Dónde vive la idempotenciaCaché de respuestas junto a los datosEn el propio registro de facturación
Vigencia de la protecciónVentana temporal (típicamente 24 h)Permanente — la vida del registro
Altas (/create)Requiere cabecera; sin ella, sin protecciónProtegidas siempre por la identidad natural de la factura; la cabecera es opcional y convierte el reintento en un 200 con replay
Qué devuelve el replayLa respuesta original, congeladaEl registro con su estado actual
Reintento concurrenteError «en curso»: el cliente reintentaSe serializa en servidor: el segundo recibe el replay

Por qué esto importa más en Verifactu que en pagos

El patrón caché nació en APIs de pagos, donde un duplicado se detecta pronto (dos cargos) y se revierte con una devolución. En Verifactu no hay devolución: un registro duplicado queda encadenado por huella y remitido a la AEAT, y una anulación duplicada es aún peor — la segunda llega a una factura ya anulada y vuelve rechazada, dejando tu ERP convencido de que la anulación falló cuando en realidad ya estaba hecha. Recuperar eso a mano es trabajo de soporte y de auditoría. Evitarlo es una propiedad del diseño de la API, y por eso creemos que la idempotencia de un sistema de facturación no puede ser una caché que se vacía cada noche.

Preguntas frecuentes

¿Qué pasa si reintento un /create sin ninguna cabecera especial?

Nada malo: la idempotencia de /create no depende de cabeceras. La identidad de la factura (serie, número y fecha de expedición dentro de tu SIF) actúa como clave natural permanente. Un reintento devuelve 409 con el registro existente completo, que puedes tratar como éxito. Si además envías la cabecera opcional Idempotency-Key, el reintento devuelve directamente 200 con el estado actual, y el 409 queda reservado para dos errores que merecen revisión: la colisión real de numeración y el alta por /create de una factura ya anulada, cuya reactivación va por /modify.

¿La Idempotency-Key de /create, /create_bulk, /modify y /cancel caduca?

No. La clave se guarda en el propio registro de facturación, con un índice único por SIF. No es una entrada de caché con tiempo de vida: mientras exista el registro — y los registros Verifactu se conservan años por obligación legal — el reintento con la misma clave devuelve ese registro y no crea otro.

¿Qué clave debo enviar?

Una clave determinista derivada de tu estado local, no un UUID aleatorio generado en cada intento. Por ejemplo, el identificador local de la factura que das de alta (crea-84213) o el del registro fiscal previo que estás subsanando o anulando (sub-84213, anu-84213). Así, un reintento tras un rollback local produce exactamente la misma clave y el replay funciona solo.

¿Qué pasa si reutilizo una Idempotency-Key con un cuerpo distinto?

La API responde 422 idempotency_key_reused_with_different_payload sin crear ni modificar nada. El replay exige misma clave y mismo cuerpo: junto a la clave se guarda un hash SHA-256 del cuerpo de la petición, y un reintento solo se reconoce como tal si ambos coinciden. Reenvía el cuerpo idéntico en los reintentos y deriva una clave nueva para cada intento lógico distinto. El caso típico: una subsanación que la AEAT rechazó y que reenvías corregida ya no es un reintento, es un intento nuevo con otro cuerpo — cambia también la clave. El formato de la clave se valida a la entrada: ASCII imprimible de 1 a 80 caracteres; si no cumple, la petición se rechaza con 400 invalid_idempotency_key.

¿Puedo volver a dar de alta una factura cuyo registro anulé?

Sí, pero no por /create: la AEAT rechaza un alta normal mientras exista cualquier registro de esa factura — de alta o de anulación — y solo admite reactivar un registro anulado mediante un alta de subsanación (Subsanacion=S). En FactuBridge esa vía es /modify, que envía siempre Subsanacion=S. Por eso /create responde 409 aunque el alta anterior esté anulada: replica el comportamiento de la Agencia y te señala la vía correcta en lugar de remitir un registro condenado al rechazo.

¿Si migro de ERP o reinstalo mi SIF, pueden chocar mis claves nuevas con las históricas?

No, y no por convención sino por normativa: el número de instalación de un SIF, definido en el anexo de la Orden HAC/1177/2024, no puede repetirse nunca. Las preguntas frecuentes para desarrolladores de la AEAT lo llevan al extremo: incluso si se formatea el equipo y se reinstala el mismo software en ese mismo ordenador, el nuevo SIF debe identificarse con otro número de instalación, que no coincida con el de ningún otro SIF de ese obligado — pasado, presente o futuro. En FactuBridge eso se traduce en una identidad nueva — el operador de FactuBridge da de alta ese nuevo SIF y te facilita un enlace para generar su API key —, y las claves de idempotencia tienen ámbito de SIF: el espacio de claves arranca vacío y la colisión con el histórico es imposible por construcción.

¿Por qué el replay devuelve el estado actual y no la respuesta original?

Porque entre tu intento perdido y el reintento la AEAT puede haber respondido. Si el replay devolviera la respuesta original congelada (estado «Pendiente»), tendrías que hacer una consulta de estado adicional para saber la verdad. Devolver el estado vigente convierte el reintento en una recuperación completa en una sola llamada.