Empezar
Idempotencia y reintentos
Cómo no timbrar dos veces la misma factura, y qué hacer cuando una petición se cae a la mitad.
Esta es la página que más caro sale saltarse. Un CFDI duplicado no es un bug cosmético: es un comprobante fiscal de más ante el SAT, un timbre gastado y una cancelación (otro timbre) para arreglarlo.
Dónde es obligatoria
idempotencyKey es un campo requerido en:
POST /invoices
POST /retenciones
POST /payments
POST /payments/{paymentId}/stamp
Debe medir entre 8 y 128 caracteres.
Derívala, no la inventes
La llave tiene que salir de tu propio registro, de forma determinista:
mi-erp:prod:factura:4471
tienda:2026:pedido:88213
dolibarr:prod:facture:4471
Nunca un UUID aleatorio, nunca una marca de tiempo. El punto es que un doble clic del usuario, un timeout de red y un replay del cron generen la misma llave, y por lo tanto el mismo CFDI.
Incluye el ambiente en el prefijo (prod / test) para que un dump de tu base de
pruebas no colisione con producción.
Qué hace el servidor con una llave repetida
| Estado previo de esa llave | Respuesta |
|---|---|
| Ya timbrado | Devuelve el mismo CFDI. No cobra un segundo timbre. |
En vuelo (pending) | 409. No reintentes: puede haber una llamada al PAC corriendo en este momento. |
Falló antes (failed) | 409, salvo que el cliente opte explícitamente por reintentar. |
El cobro del timbre se controla con la misma llave, de forma independiente, así que los dos no pueden desincronizarse: es imposible acabar con un CFDI emitido y dos timbres cobrados, o al revés.
Reintentar algo que falló
Un cliente que deriva su llave de forma determinista se quedaría atrapado: si el
primer intento falla por una clave del SAT mal capturada, la llave no cambia y
todos los intentos siguientes contestan 409.
Para eso está allowRetryOnFailed, en POST /invoices de tipo ingreso y
egreso:
{
"type": "ingreso",
"idempotencyKey": "mi-erp:prod:factura:4471",
"allowRetryOnFailed": true,
"…": "…"
}
Es seguro: failed significa que no se emitió ningún CFDI y que el timbre se
reembolsó. La garantía que importa —no timbrar dos veces— no se toca, porque
pending (resultado desconocido) sigue devolviendo 409 y te obliga a
reconciliar antes.
Un timeout no es un fracaso: es un desconocido
Si tu petición revienta por timeout, o recibes un 5xx, no sabes si el CFDI
se emitió. Puede que haya llegado al PAC y solo se haya perdido la respuesta.
Nunca reintentes a ciegas. Resuelve primero:
# 1) por tu propia llave de idempotencia — la vía preferente
curl -s "$FACTY_HOST/api/authenticated/$FACTY_ORG/invoices?idempotencyKey=mi-erp:prod:factura:4471" \
-H "X-API-Key: $FACTY_KEY" | jq
# 2) por el folio fiscal, si es lo único que tienes
curl -s "$FACTY_HOST/api/authenticated/$FACTY_ORG/invoices?uuid=A1B2C3D4-…" \
-H "X-API-Key: $FACTY_KEY" | jq
La respuesta te devuelve el idempotencyKey de cada fila, precisamente para que
puedas verificar que el registro que recibiste es el que preguntaste, en vez
de confiar en que el filtro se aplicó.
Los estados posibles y qué hacer:
status | Significa | Qué haces |
|---|---|---|
stamped | Sí se emitió. Trae uuid. | Guárdalo. Ya está. |
pending | Sigue en vuelo. | Espera y vuelve a consultar. No reintentes el POST. |
failed | No se emitió; el timbre se reembolsó. | Corrige el dato y reintenta con allowRetryOnFailed. |
| Lista vacía | Nunca llegó. | Reintenta el POST con la misma llave. |
Un cliente correcto, en pseudocódigo
def stamp(invoice):
key = f"mi-erp:prod:factura:{invoice.id}"
try:
r = post("/invoices", body={**payload(invoice), "idempotencyKey": key})
return r["uuid"]
except (Timeout, ServerError):
# NO reintentar aquí. Reconciliar.
found = get("/invoices", params={"idempotencyKey": key})["invoices"]
if found and found[0]["status"] == "stamped":
return found[0]["uuid"]
if found and found[0]["status"] == "pending":
raise RetryLater() # vuelve en unos segundos
raise NeedsManualReview() # o reintenta con allowRetryOnFailed
Y una regla que no es técnica
Nunca re-timbres de forma automática. Ni en un backfill, ni en un job nocturno, ni en un "reintentar todo lo fallido". Re-timbrar consume un timbre, genera un UUID nuevo y suele implicar cancelar el original: es una decisión fiscal de la persona dueña de la empresa, no de tu cron.