Guía · Técnica
Idempotencia en una API Verifactu: por qué una clave que caduca no basta
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é.
- La protección caduca. Un reintento fuera de la ventana — un proceso por lotes que se retoma tras un fin de semana, una recuperación de incidente el martes de algo que pasó el viernes, una cola que se drena tarde — llega cuando el servidor ya ha olvidado la clave. Y un reintento a más de 24 horas no es ciencia ficción: elgran apagón peninsular de abril de 2025 dejó zonas sin suministro eléctrico hasta 24 horas — una cola de reintentos congelada aquel día se habría drenado justo cuando las claves expiraban —, y una tormenta solar intensa puede provocar cortes de comunicaciones aún más prolongados. Resultado: registro duplicado, exactamente lo que la cabecera prometía evitar. Y en Verifactu un duplicado no es cosmético: es un registro de más encadenado y remitido a la Agencia Tributaria.
- El replay devuelve el pasado. Lo guardado es la respuesta original. Si tu primera llamada obtuvo «Pendiente» y la AEAT respondió entre medias, el reintento te repite «Pendiente»: necesitas una consulta de estado adicional para saber qué pasó de verdad.
- La concurrencia vuelve a ser tu problema. Si el reintento llega mientras la petición original sigue en curso, el patrón caché no tiene nada que devolver, así que responde un error («petición en curso, reintente en unos segundos») y te devuelve la carrera al cliente: otro bucle de reintento que escribir y probar.
- Es opt-in en la operación más frecuente. Si el que integra olvida la cabecera en
/create— o la genera mal: un UUID nuevo por reintento no protege nada — no hay red de seguridad.
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:
- Sin caducidad. El registro Verifactu se conserva durante años por obligación legal; su clave de idempotencia, también. No existe el «reintento tardío que ya no está protegido».
- El replay devuelve el estado actual, no una foto del pasado. Si la AEAT respondió entre tu intento perdido y el reintento, el reintento ya te trae «Correcto» (o el rechazo, con sus errores). Una llamada, recuperación completa.
- La concurrencia se resuelve en el servidor. Las operaciones sobre un mismo SIF se serializan internamente: si dos reintentos con la misma clave llegan a la vez, el segundo espera y recibe el replay del primero. Sin errores de «vuelva usted en unos segundos», sin bucles extra en tu código.
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ón | Clave recomendada | Por qué es estable |
|---|---|---|
| Alta | crea-{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. |
| Lote | lote-{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ón | sub-{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ón | anu-{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:
- El fallo por colisión es ruidoso, nunca silencioso.Si una clave se repite con un cuerpo distinto, la respuesta es un
422explícito que no crea ni modifica nada, y el remedio es trivial: derivar otra clave. Compara los modos de fallo de cada diseño: el patrón caché falla en silencio — un duplicado encadenado y remitido a la AEAT que descubres en una auditoría —; la permanencia falla a gritos en el momento, con un error que apunta a la causa. - Las migraciones no pueden colisionar — por normativa.Podrías pensar: «si cambio de ERP y el nuevo reinicia sus ids en 1, chocaré con las claves del sistema anterior». No puede ocurrir. El número de instalación de un SIF, definido en el anexo de la Orden HAC/1177/2024, «no puede repetirse nunca», y las preguntas frecuentes para desarrolladores de la AEAT llevan la regla al extremo: incluso si se formatea el ordenador y se reinstala el mismo software en ese mismo equipo, el nuevo SIF debe llevar 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 es una identidad nueva ante la API — el operador de FactuBridge da de alta ese nuevo SIF y te facilita un enlace para generar su API key — y las claves tienen ámbito de SIF: el espacio de claves arranca vacío. La misma normativa que obliga a conservar los registros durante años hace imposible por construcción la colisión con el histórico.
- El caso residual: restaurar una copia antigua. Volver a una copia de seguridad de tu base de datos sobre el mismo SIF puede reasignar ids locales a documentos distintos de los que ya se enviaron. Es exactamente el escenario para el que existe el contrato «misma clave, mismo cuerpo»: el choque aflora como
422— nunca como registro duplicado — y, en las altas, la clave natural de la factura sigue protegiendo por debajo aunque la cabecera se pierda en la restauración.
Resumen comparativo
| Patrón caché con TTL | FactuBridge | |
|---|---|---|
| Dónde vive la idempotencia | Caché de respuestas junto a los datos | En el propio registro de facturación |
| Vigencia de la protección | Ventana temporal (típicamente 24 h) | Permanente — la vida del registro |
Altas (/create) | Requiere cabecera; sin ella, sin protección | Protegidas siempre por la identidad natural de la factura; la cabecera es opcional y convierte el reintento en un 200 con replay |
| Qué devuelve el replay | La respuesta original, congelada | El registro con su estado actual |
| Reintento concurrente | Error «en curso»: el cliente reintenta | Se 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.