CFDI Timbrado Outbox: Never Lose or Double-Stamp an Invoice When Your PAC Fails — Cesar Ayala
← All posts

CFDI Timbrado Outbox: Never Lose or Double-Stamp an Invoice When Your PAC Fails

CFDI uniqueness comes from the cadena original, not the folio. Resend a byte-identical comprobante and the PAC returns the same UUID; change the Fecha and it stamps a duplicate. The fix: in the same DB transaction as the payment, write an outbox row carrying a frozen comprobante. A worker drains it at-least-once against an idempotent PAC — exactly-once effect.

When a PAC Failure Costs You Money — the Short Answer

CFDI uniqueness comes from the cadena original of the comprobante, not your commercial folio. Resend a byte-identical comprobante and the PAC returns the same UUID; change the Fecha and it stamps a duplicate with a new UUID. The fix is structural: in the same database transaction as the payment, write an outbox row carrying a frozen comprobante. A worker drains that outbox at-least-once against an idempotent PAC, and the combination produces an exactly-once stamping effect.

I’m an engineer who ships timbrado pipelines, not your contador. The cancellation and acceptance rules below have real fiscal consequences — validate the SAT-facing specifics with whoever signs your declarations. What follows is the systems-engineering layer.

Why You Lose Money Here: the Dual-Write Between the PAC Stamp and Your DB

A naive webhook handler does two writes to two different systems. It calls the PAC to stamp the CFDI, then writes the returned UUID into your own database. These are not in one transaction — one is an HTTP call to a third party, the other is a local commit.

// The bug: two writes, no atomicity
async function onPaymentSucceeded(payment) {
  const xml = buildComprobante(payment);          // generates Fecha = now()
  const { uuid, timbre } = await pac.stamp(xml);  // (1) money spent at SAT/PAC
  await db.invoices.insert({ payment, uuid });    // (2) crash here = orphan
}

If the process dies between (1) and (2), you paid the PAC for a stamp your system never recorded. The next retry re-runs buildComprobante, which calls now() again, producing a different Fecha, a different cadena original, and therefore a brand-new UUID. You just double-stamped. This dual-write gap is where every lost or duplicated invoice originates.

Payment event
Build XML (Fecha=now)
PAC stamp to UUID
Crash
DB insert UUID

CFDI Uniqueness Is the Cadena Original, Not the Folio

Your commercial folio is a label you control; it means nothing to SAT for deduplication. The PAC and SAT dedup on the cadena original of the comprobante plus its sello. The cadena original is the pipe-delimited string built from the comprobante’s fields per the SAT XSLT — issuer, receiver, conceptos, totals, and critically the Fecha.

||4.0|FAC-1042|2026-06-30T12:00:00|...|EKU9003173C9|...|1160.00|MXN|...||
        ^folio (cosmetic)  ^Fecha (feeds the hash, defines uniqueness)

The practical rule for retries: identity is the cadena original. If you resend a comprobante whose cadena original is byte-for-byte identical to one already certified, the PAC recognizes it as the same CFDI. If any input to the cadena original moves — most commonly Fecha — it is, by definition, a different comprobante.

The Two Failure Modes

There are exactly two ways the dual-write hurts you, and they pull in opposite directions.

The orphaned stamp. You stamped successfully, paid the PAC, and crashed before persisting the UUID. The CFDI exists at SAT; your system has no record. The customer may never receive it, and your reconciliation is short one invoice you already paid for.

The duplicate from a changed Fecha. Your retry regenerates the XML, Fecha advances, the cadena original changes, and the PAC stamps a second valid CFDI for the same sale. Now you owe a cancellation — and cancellations have their own SAT acceptance rules.

Orphaned stamp

  • PAC stamped, you paid
  • DB never got the UUID
  • CFDI exists at SAT, invisible to you
  • Customer may never receive it

Duplicate stamp

  • Naive retry regenerated XML
  • Fecha moved to new cadena original
  • PAC minted a second UUID
  • You must now cancel one

Why a Byte-Identical Resend Returns the Same UUID

This is the property the whole pattern leans on. When you resend a comprobante whose cadena original was already certified, the PAC returns the existing timbre and hands back the same UUID — it recognizes the already-stamped cadena original instead of double-stamping. Idempotent by construction.

Regenerate the XML so anything feeding the cadena original changes — Fecha is the usual culprit — and the PAC has no reason to think it has seen this comprobante before. It stamps it fresh and assigns a new UUID. This single behavior is the number-one cause of duplicate invoices in production.

The corollary: retries must replay bytes, not regenerate them. If your retry path rebuilds the comprobante, you have not built a retry — you’ve built a duplicate factory.

Freeze the Comprobante

Make the comprobante deterministic before you ever call the PAC. Three things must be frozen at creation time:

  1. Fecha — set it once, from the business event, and store it. Never now() on a retry.
  2. Content — issuer, receiver, conceptos, totals: serialize the exact XML once and persist it.
  3. A deterministic stamp key — a stable idempotency key derived from the business event, not a random per-attempt value.
import hashlib

def freeze_comprobante(payment) -> dict:
    fecha = payment.occurred_at.strftime("%Y-%m-%dT%H:%M:%S")  # frozen, not now()
    xml = build_cfdi_xml(payment, fecha=fecha)                 # serialized once
    # deterministic key derived from the business event, stable across retries
    stamp_key = hashlib.sha256(
        f"{payment.id}:{fecha}".encode()
    ).hexdigest()
    return {"xml": xml, "fecha": fecha, "stamp_key": stamp_key}

Once frozen, the comprobante is an immutable artifact. Every retry — same process, different worker, after a backup-PAC failover — replays this exact xml, so the cadena original never moves and the UUID stays deterministic.

The Transactional Outbox: One DB Transaction for the Payment and the Outbox Row

Kill the dual-write by collapsing it into a single local transaction. You do not call the PAC in the request path. Instead, in the same transaction that records the payment, you insert an outbox row carrying the frozen comprobante. Both commit or neither does.

CREATE TABLE cfdi_outbox (
  id            BIGSERIAL PRIMARY KEY,
  payment_id    TEXT        NOT NULL UNIQUE,   -- one stamp per business event
  stamp_key     TEXT        NOT NULL UNIQUE,   -- deterministic idempotency key
  fecha         TIMESTAMP   NOT NULL,          -- frozen Fecha
  comprobante   TEXT        NOT NULL,          -- frozen XML bytes
  status        TEXT        NOT NULL DEFAULT 'pending', -- pending|done|failed
  uuid          TEXT,                          -- filled after stamping
  attempts      INT         NOT NULL DEFAULT 0,
  locked_until  TIMESTAMP,
  created_at    TIMESTAMP   NOT NULL DEFAULT now()
);
BEGIN;
  INSERT INTO payments (id, amount, status)
  VALUES ($1, $2, 'succeeded');

  INSERT INTO cfdi_outbox (payment_id, stamp_key, fecha, comprobante)
  VALUES ($1, $3, $4, $5)
  ON CONFLICT (payment_id) DO NOTHING;   -- redelivered webhook = no-op
COMMIT;

After COMMIT, the fact “this payment must be stamped, with these exact bytes” is durable. The webhook can return 200 immediately. Nothing has been stamped yet, and that’s fine — the outbox guarantees it will be, eventually, exactly once.

  1. Payment event arrivesWebhook handler opens one DB transaction.
  2. Freeze the comprobanteDeterministic Fecha, serialized XML, stable stamp_key.
  3. Insert payment + outbox rowSame transaction; ON CONFLICT DO NOTHING for redeliveries.
  4. Commit and ackReturn 200. No PAC call in the request path.

The Drain Worker: At-Least-Once Delivery, Idempotent Stamp, Persist UUID, Mark Done

A separate worker polls the outbox, claims rows with a lease, and calls the PAC. Because the comprobante is frozen and the PAC is idempotent on the cadena original, the worker can be as crash-happy as it likes — at-least-once delivery is safe.

def drain_once(db, pac, max_attempts=8):
    row = db.execute("""
        UPDATE cfdi_outbox
        SET locked_until = now() + interval '60 seconds',
            attempts = attempts + 1
        WHERE id = (
            SELECT id FROM cfdi_outbox
            WHERE status = 'pending'
              AND (locked_until IS NULL OR locked_until < now())
            ORDER BY id
            FOR UPDATE SKIP LOCKED
            LIMIT 1
        )
        RETURNING id, comprobante, stamp_key, attempts;
    """).fetchone()
    if not row:
        return

    try:
        # Idempotent stamp: frozen bytes + deterministic key.
        # Retry of an already-certified comprobante returns the SAME UUID.
        result = pac.stamp(xml=row.comprobante, custom_id=row.stamp_key)
    except PacError:
        if row.attempts >= max_attempts:
            # dead-letter: stop retrying, page a human
            db.execute("UPDATE cfdi_outbox SET status='failed', locked_until=NULL WHERE id=%s", (row.id,))
        # else: lease expires, another worker re-claims and replays the SAME bytes
        raise

    db.execute("""
        UPDATE cfdi_outbox
        SET status = 'done', uuid = %s, locked_until = NULL
        WHERE id = %s
    """, (result.uuid, row.id))

If the worker crashes after pac.stamp but before the UPDATE, the lease expires, another worker re-claims the row, resends the same frozen bytes, and the PAC returns the same UUID — no double-stamp. At-least-once delivery times an idempotent stamp equals an exactly-once effect.

PAC Idempotency Keys: SW customId / Error CFDI3307

Some PACs expose an explicit idempotency key on top of the cadena-original dedup — check what yours offers. SW Sapién, for example, accepts a customId on the stamp call; reuse it and the PAC returns the original result, or raises CFDI3307 “customId duplicado” if you try to reuse it with different content. Treat a customId collision as confirmation that the stamp already happened:

try:
    result = pac.stamp(xml=row.comprobante, custom_id=row.stamp_key)
    uuid = result.uuid
except PacError as e:
    if e.code == "CFDI3307":              # customId duplicado
        uuid = pac.fetch_by_custom_id(row.stamp_key).uuid  # recover original
    else:
        raise

Not every PAC has a customId. If yours doesn’t, you still get idempotency from the frozen cadena original — the customId is a belt-and-suspenders layer, not the foundation. Confirm the exact field name, error code, and validity window against SW’s stamp API docs or your PAC’s equivalent.

Multi-PAC Failover: Reuse the Same Frozen Comprobante

SAT lets you stamp through multiple authorized PACs, so keep a backup for when your primary has an outage. The discipline that makes failover safe: fail over with the same frozen comprobante. Do not rebuild the XML for the backup PAC.

def stamp_with_failover(row, primary, backup):
    try:
        return primary.stamp(xml=row.comprobante, custom_id=row.stamp_key)
    except PacUnavailable:
        # SAME frozen bytes -> SAME cadena original -> SAME UUID determinism
        return backup.stamp(xml=row.comprobante, custom_id=row.stamp_key)

Because every authorized PAC stamps against the same SAT certification rules, an identical cadena original yields a deterministic UUID regardless of which PAC routes it. The frozen comprobante is what makes the two PACs interchangeable. (One edge: if the primary did certify before timing out, query it for the existing timbre before failing over, so you don’t stamp the same comprobante at two PACs.)

You Already Double-Stamped — Now What

If a duplicate slipped through before you deployed the outbox, you cannot delete a CFDI — you cancel it through the SAT CFDI cancellation flow, which carries its own acceptance rules (the receiver may have to accept the cancellation). Cancel the duplicate, keep the one your customer actually received, and reconcile your DB to that surviving UUID.

This is the boundary where I hand off to your contador: which UUID survives, how the cancellation maps to your books, and whether a substitution (Sustitución) relationship is required are fiscal decisions. For the cancellation-adjacent flows, see CFDI de Egreso for refunds and credit notes, and review the official SAT cancellation guidance and CFF Art. 29 / 29-A with your accountant.

At-Least-Once × Idempotent Stamping = Exactly-Once

Put the pieces together and the guarantee falls out cleanly:

  • The transactional outbox removes the dual-write — payment and stamp-intent commit atomically.
  • The frozen comprobante keeps the cadena original byte-stable, so every retry is the same CFDI.
  • The idempotent PAC (cadena-original dedup, plus a customId where available) returns the same UUID on resend.
  • The drain worker delivers at-least-once and persists the UUID before marking the row done.
Orphaned stamps0
Duplicate UUIDs0
Lost webhooks0
Exactly-once stamps100

At-least-once delivery times an idempotent stamp equals an exactly-once effect — the same property you’d want behind any payment-driven invoice. This pairs directly with the auto-invoicing from Stripe/Mercado Pago pipeline, and the same outbox carries CFDI de Nómina, Complemento de Pago (REP), and factura global stamps without changing the shape. Start from the Mexico CFDI invoicing hub if you’re wiring the whole stack.

Again: I’m the engineer who keeps the stamping idempotent — the fiscal acceptance, cancellation, and substitution rules belong to your contador.