
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.
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:
Fecha— set it once, from the business event, and store it. Nevernow()on a retry.- Content — issuer, receiver, conceptos, totals: serialize the exact XML once and persist it.
- 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.
- Payment event arrivesWebhook handler opens one DB transaction.
- Freeze the comprobanteDeterministic Fecha, serialized XML, stable stamp_key.
- Insert payment + outbox rowSame transaction; ON CONFLICT DO NOTHING for redeliveries.
- 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
customIdwhere available) returns the same UUID on resend. - The drain worker delivers at-least-once and persists the UUID before marking the row done.
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.