Outbox para timbrar CFDI: nunca pierdas ni dupliques un timbrado cuando tu PAC falla — Cesar Ayala
← Todos los artículos

Outbox para timbrar CFDI: nunca pierdas ni dupliques un timbrado cuando tu PAC falla

La unicidad del CFDI viene de la cadena original, no del folio. Reenvía un comprobante idéntico byte a byte y el PAC devuelve el mismo UUID; cambia la Fecha y timbra un duplicado. La solución: en la misma transacción que el pago, escribe una fila de outbox con un comprobante congelado. Un worker la drena at-least-once contra un PAC idempotente — efecto exactly-once.

Outbox para timbrar CFDI: la respuesta corta

La unicidad de un CFDI no la da tu folio comercial — la da la cadena original del comprobante. Si reenvías un comprobante idéntico byte a byte (misma cadena original), el PAC te devuelve el mismo UUID y no vuelve a timbrar. Pero si regeneras el XML y cambia la Fecha (o cualquier dato que alimente la cadena original), el PAC lo trata como un CFDI distinto y te asigna un UUID nuevo: ahí nace el duplicado. La solución es transaccional: en la misma transacción de base de datos que el evento de pago escribes una fila de outbox con un comprobante congelado. Un worker la drena at-least-once contra un PAC idempotente, y el efecto neto es exactly-once.

Esto es una guía de ingeniero, no de contador. Aquí aprendes el patrón de outbox, la congelación del comprobante y la idempotencia del timbrado. Las reglas fiscales vigentes, los catálogos y el flujo de cancelación los confirmas contra el SAT y la documentación de tu PAC.

Aquí es donde pierdes dinero: la doble escritura entre el timbre del PAC y tu DB

El bug profundo no está en tu XML. Está en el dual-write de tu handler: timbras contra el PAC y después escribes el UUID en tu base de datos. Son dos sistemas distintos, dos llamadas distintas, sin una transacción que las abrace.

# El anti-patron. Dos efectos, cero atomicidad.
def on_payment_succeeded(payment):
    xml = build_cfdi(payment)          # genera Fecha = now()
    timbre = pac.stamp(xml)            # (1) efecto en el PAC: gastas un timbre
    db.invoices.insert(                # (2) efecto en tu DB
        payment_id=payment.id,
        uuid=timbre.uuid,
    )

Si el proceso se cae entre (1) y (2), o el insert falla, o el contenedor se reinicia, quedas en un estado inconsistente que ningún reintento ingenuo arregla bien. Pagaste un timbre que tu DB no conoce, o vas a regenerar el XML con una Fecha nueva y timbrar de nuevo. Esa ventana entre las dos escrituras es exactamente donde pierdes dinero.

Efecto 1pac.stamp(xml) gasta un timbre y genera un UUID en el PAC.
Efecto 2db.insert escribe ese UUID en tu base de datos.
El crashSi mueres entre ambos, o pierdes el timbre (huérfano) o duplicas al reintentar.

La unicidad del CFDI es la cadena original, no el folio (y qué significa para los reintentos)

El PAC y el SAT deduplican sobre la cadena original del comprobante (más el sello), no sobre tu folio comercial. La cadena original es la representación canónica de los datos timbrables: emisor, receptor, conceptos, importes y — clave para esto — la Fecha del comprobante.

La consecuencia para tus reintentos es directa: si después de un timeout de WS o un error de comunicación reenvías el mismo comprobante byte a byte, su cadena original es idéntica, y el PAC reconoce que ya lo certificó. Te devuelve el timbre existente y el mismo UUID, sin volver a timbrar — idempotente sobre la cadena original ya timbrada. Eso es lo que quieres.

Comprobante (Fecha, Emisor, Receptor, Conceptos, Total)
        |  transformacion canonica (XSLT del SAT)
        v
cadena original  -->  sello  -->  identidad del CFDI ante el PAC/SAT

El error mental que casi todos cometen: creer que el folio o un id de tu negocio identifican el comprobante ante el PAC. No es así. Cambia un solo carácter que entre a la cadena original y, para el PAC, es otro CFDI.

Los dos modos de falla: el timbre huérfano que pagaste y el duplicado por una Fecha cambiada

Del dual-write salen exactamente dos formas de perder:

Modo A — el timbre huérfano. Timbras en el PAC, gastas el folio, obtienes un UUID válido… y mueres antes de persistirlo. Tu DB no sabe que existe. El comprobante está ante el SAT, pero tú no puedes entregarlo ni referenciarlo. Pagaste por un timbre fantasma.

Modo B — el duplicado. Tu DB no tiene el UUID (por el modo A, o por un error transitorio), así que un reintento ingenuo regenera el XML. Como build_cfdi pone Fecha = now(), la nueva Fecha cambia la cadena original, el PAC lo ve como un comprobante distinto y emite un UUID nuevo. Ahora tienes dos CFDI vigentes por la misma venta. Este es el motivo número uno de facturas duplicadas en producción.

Modo A: timbre huerfano

  • Timbras y obtienes UUID
  • Mueres antes del insert
  • La DB no conoce el UUID
  • Pagaste un timbre que no puedes usar

Modo B: duplicado

  • Reintento regenera el XML
  • Fecha = now() cambia la cadena original
  • El PAC genera un UUID nuevo
  • Dos CFDI vigentes por una venta

Por qué un reenvío idéntico byte a byte devuelve el mismo UUID — y regenerar el XML crea uno nuevo

La regla operativa que tienes que tatuarte: en un reintento de timbrado, reenvía el mismo comprobante, no lo regeneres.

# MAL: el reintento regenera, la Fecha cambia, nace un duplicado.
def retry_bad(payment):
    xml = build_cfdi(payment)   # Fecha = now()  -> cadena original distinta
    return pac.stamp(xml)       # UUID NUEVO

# BIEN: reenvia el comprobante ya congelado, idéntico byte a byte.
def retry_good(frozen_xml):
    return pac.stamp(frozen_xml)  # misma cadena original -> MISMO UUID

pac.stamp(frozen_xml) con un XML idéntico produce la misma cadena original; el PAC detecta que ya lo certificó y te regresa el timbre existente. build_cfdi(payment) en cada intento es una bomba: cualquier campo no determinista — la Fecha, un id autogenerado, un orden de nodos distinto — rompe la igualdad byte a byte y te cuesta un duplicado. La idempotencia del timbrado depende de que el insumo sea inmutable.

Congela el comprobante: Fecha determinista, contenido fijo y una llave de timbrado determinista

“Congelar” significa: la primera vez que decides facturar un pago, construyes el comprobante una sola vez, lo serializas y lo guardas tal cual. Todo reintento posterior reusa ese blob, nunca lo reconstruye.

Tres propiedades hacen al comprobante determinista:

  • Fecha fija. Se calcula una vez, en el momento del evento de negocio, y se persiste. Nunca now() dentro del worker.
  • Contenido fijo. Mismos conceptos, mismos importes, mismo orden de nodos, misma serialización. El XML que guardas es el XML que reenvías.
  • Llave de timbrado determinista. Una clave derivada del evento (no aleatoria) que identifica este timbrado a través de reintentos y, si lo soporta, ante el PAC.
import hashlib, json

def freeze_comprobante(payment) -> dict:
    fecha = payment.occurred_at.isoformat(timespec="seconds")  # determinista
    xml = build_cfdi(payment, fecha=fecha)                     # se construye UNA vez
    # llave de timbrado determinista, derivada del evento (no aleatoria)
    stamp_key = hashlib.sha256(
        json.dumps({"payment_id": payment.id, "fecha": fecha},
                   sort_keys=True).encode()
    ).hexdigest()
    return {"fecha": fecha, "xml": xml, "stamp_key": stamp_key}

A partir de aquí, xml y stamp_key son inmutables. El worker solo los lee.

El patrón outbox transaccional: una sola transacción para el pago y la fila de outbox (esquema SQL)

El truco que cierra la ventana del dual-write: en lugar de timbrar dentro del handler, escribe una intención de timbrar en una tabla de outbox dentro de la misma transacción que el evento de negocio. Una sola transacción, dos filas, atomicidad real. O se confirman ambas o ninguna.

CREATE TABLE cfdi_outbox (
    id             BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payment_id     TEXT NOT NULL,
    stamp_key      TEXT NOT NULL UNIQUE,   -- llave de timbrado determinista
    frozen_xml     TEXT NOT NULL,          -- comprobante congelado, byte a byte
    fecha          TIMESTAMPTZ NOT NULL,   -- Fecha determinista del comprobante
    status         TEXT NOT NULL DEFAULT 'pending',  -- pending|done|failed
    uuid           TEXT,                   -- se llena al timbrar
    attempts       INT NOT NULL DEFAULT 0,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON cfdi_outbox (status) WHERE status = 'pending';
def on_payment_succeeded(payment):
    frozen = freeze_comprobante(payment)
    with db.transaction():                       # UNA sola transacción
        db.payments.mark_invoiced(payment.id)    # evento de negocio
        db.cfdi_outbox.insert(                    # intención de timbrar
            payment_id=payment.id,
            stamp_key=frozen["stamp_key"],
            frozen_xml=frozen["xml"],
            fecha=frozen["fecha"],
            status="pending",
        )
    # NO timbramos aquí. El handler termina rápido y atómico.

El UNIQUE sobre stamp_key es tu red: si el mismo evento llega dos veces (webhooks reintentados de Stripe o Mercado Pago), el segundo insert choca y no duplicas la fila. Este es el mismo patrón de idempotencia que aplicas al facturar CFDI automáticamente desde Stripe o Mercado Pago y al automatizar el Complemento de Pago (REP).

  1. CongelarConstruye el comprobante una vez: Fecha fija, XML fijo, stamp_key determinista.
  2. Una transacciónMarca el pago facturado e inserta la fila de outbox juntos, atómicos.
  3. DrenarEl worker toma filas pending y timbra contra el PAC reusando el frozen_xml.
  4. Persistir UUIDGuarda el UUID y marca la fila done en una sola transacción.

El worker que drena el outbox: entrega at-least-once, timbrado idempotente, persistir UUID, marcar listo

El worker es independiente del handler. Toma filas pending, reenvía el frozen_xml y persiste el resultado. Como el comprobante está congelado y el PAC es idempotente sobre la cadena original, reintentar es seguro: la entrega es at-least-once, pero el efecto observable es exactly-once.

def drain_outbox():
    for row in db.cfdi_outbox.select_pending(limit=50):
        try:
            # reenvia el comprobante congelado, idéntico byte a byte
            timbre = pac.stamp(row.frozen_xml, idempotency_key=row.stamp_key)
            with db.transaction():
                db.cfdi_outbox.update(
                    row.id, status="done", uuid=timbre.uuid,
                    attempts=row.attempts + 1,
                )
                db.invoices.upsert(payment_id=row.payment_id, uuid=timbre.uuid)
        except PacTransientError:
            db.cfdi_outbox.bump_attempts(row.id)   # se reintenta en el próximo ciclo
        except PacPermanentError as e:
            db.cfdi_outbox.update(row.id, status="failed", attempts=row.attempts + 1)
            alert(row.payment_id, e)               # dead-letter: requiere ojo humano

Si el worker se cae justo después de timbrar pero antes de marcar done, el siguiente ciclo retoma la misma fila pending, reenvía el mismo frozen_xml, y el PAC regresa el mismo UUID. Nada se duplica. Esa es toda la magia: idempotencia en el insumo, no en la suerte.

Llaves de idempotencia del PAC: customId de SW / error CFDI3307 — revisa qué expone tu PAC

Reenviar el comprobante congelado ya te da idempotencia sobre la cadena original. Algunos PAC además exponen una llave de idempotencia explícita como capa extra — pero esto es específico de cada proveedor, así que revisa tu documentación.

Por ejemplo, SW Sapién expone un customId: si reusas el mismo customId, te devuelve el resultado original; si lo reusas en otro intento conflictivo, levanta el error CFDI3307 “customId duplicado”. Mapea tu stamp_key a esa llave (idempotency_key es el nombre genérico; customId es como SW la llama) si tu PAC la soporta.

def stamp_with_idempotency(frozen_xml, stamp_key):
    try:
        return pac.stamp(frozen_xml, customId=stamp_key)
    except PacError as e:
        if e.code == "CFDI3307":           # customId duplicado en SW
            return pac.fetch_by_custom_id(stamp_key)  # recupera el timbre original
        raise

No todos los PAC exponen una llave así, ni con la misma ventana de validez. Si el tuyo no la tiene, tu defensa sigue siendo el comprobante congelado: misma cadena original, mismo UUID. La llave del PAC es cinturón sobre tirantes, no el tirante.

Failover a un PAC de respaldo: reusa el mismo comprobante congelado para que el UUID siga siendo determinista

El SAT permite operar con varios PAC. Cuando tu PAC primario tiene un outage, haces failover a uno de respaldo — pero con una regla inviolable: reusa exactamente el mismo frozen_xml. La cadena original no cambia entre proveedores, así que el determinismo de la identidad del comprobante se mantiene.

PACS = [pac_primary, pac_backup]

def stamp_failover(frozen_xml, stamp_key):
    last = None
    for pac in PACS:
        try:
            # MISMO comprobante congelado en ambos PAC; misma cadena original
            return pac.stamp(frozen_xml, idempotency_key=stamp_key)
        except PacTransientError as e:
            last = e
            continue
    raise last

El riesgo a evitar: nunca dejes que el failover reconstruya el XML para “adaptarlo” al PAC de respaldo. Si lo regeneras y la Fecha o el orden de nodos cambia, vuelves al modo B y duplicas. El comprobante congelado es lo único que cruza entre proveedores.

Ya timbraste dos veces — ¿ahora qué?: cancela el duplicado por el flujo de cancelación del CFDI

Si ya tienes dos CFDI vigentes por una venta, no los borras: cancelas el duplicado por el flujo de cancelación del CFDI, que tiene sus propias reglas de aceptación ante el SAT (incluido el proceso de solicitud y, según el caso, la aprobación del receptor). Quédate con el comprobante correcto y cancela el otro, registrando el motivo y, si aplica, el UUID que lo sustituye.

def fix_duplicate(keep_uuid, cancel_uuid):
    # cancela el duplicado; el SAT tiene reglas de aceptacion propias
    pac.cancel(
        uuid=cancel_uuid,
        motivo="01",                 # confirma la clave de motivo vigente del SAT
        folio_sustitucion=keep_uuid, # cuando el motivo lo exige
    )
    db.invoices.mark_canceled(cancel_uuid)

Las claves de motivo y los plazos cambian, así que confírmalos contra el SAT y tu PAC. Para los flujos adyacentes a cancelación — reembolsos y ajustes negativos — usa un CFDI de Egreso en lugar de cancelar el original cuando corresponda. La mejor cancelación es la que nunca tienes que hacer: el outbox congelado existe justo para no llegar aquí.

At-least-once x timbrado idempotente = exactly-once: arma el pipeline completo

Junta las piezas y el invariante se sostiene solo:

  • Congela el comprobante la primera vez: Fecha determinista, XML byte a byte, stamp_key derivada del evento.
  • Una transacción escribe el evento de negocio y la fila de outbox juntos. Cero ventana de dual-write.
  • El worker drena at-least-once, reenvía el frozen_xml y persiste el UUID en su propia transacción.
  • El PAC es idempotente sobre la cadena original (y, si lo soporta, sobre customId / CFDI3307).
  • El failover reusa el mismo comprobante congelado, así que el UUID sigue siendo determinista entre PAC.
Timbres huerfanos0
UUID duplicados0
Webhooks perdidos0
Timbrado exactly-once100

Entrega at-least-once por reintentos × timbrado idempotente por congelación = efecto exactly-once. Ni huérfanos ni duplicados. Si quieres ver el resto del ecosistema CFDI que cuelga de este mismo patrón, arranca en el hub de facturación CFDI, y revisa cómo aplica al timbrado de CFDI de Nómina por API.

Advertencia ingeniero-no-contador, explícita: yo soy ingeniero y sí he puesto esto en producción contra el SAT y varios PAC — no soy tu contador. Este post te da el patrón de outbox, la congelación del comprobante y la idempotencia del timbrado. Las claves de catálogo, las reglas de la cadena original, los motivos de cancelación y los plazos los confirmas contra el SAT y la documentación vigente de tu PAC; cambian, y tu memoria no es la fuente de verdad.

Fuentes y referencias

Verifica siempre contra las fuentes oficiales vigentes, no contra este post:

El comprobante congelado más una sola transacción que escriba el outbox junto al evento de negocio es lo que convierte un dual-write frágil en un pipeline que ni pierde ni duplica timbres. El resto es plomería: un worker que reintenta y un PAC que ya sabe deduplicar por ti.