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 llaveRespuesta
Ya timbradoDevuelve 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:

statusSignificaQué haces
stampedSí se emitió. Trae uuid.Guárdalo. Ya está.
pendingSigue en vuelo.Espera y vuelve a consultar. No reintentes el POST.
failedNo se emitió; el timbre se reembolsó.Corrige el dato y reintenta con allowRetryOnFailed.
Lista vacíaNunca 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.