October 20, 2026 · 3 min read
From payment webhook to fiscal document with a transactional outbox
How Instant turns every confirmed dLocal payment or refund into exactly one DGI e-invoice: a transactional outbox, an SQS relay, and an invoicing service that records first and checks InvoiCy before every send.
In Uruguay every sale has to be reported to the tax authority (DGI) as an electronic fiscal document (CFE): an e-Ticket or e-Factura for a sale, a credit note for a refund. At Instant, companies buy credits and subscriptions through dLocal, and every confirmed payment or refund must end up as exactly one CFE: not zero, and not two.
The problem
Calling the invoicing provider from the webhook handler ties three external systems into one request. If InvoiCy is slow, the webhook times out. If dLocal retries, you can invoice twice. If the process dies between “payment saved” and “CFE sent”, you invoice zero times.
Step 1: accept the payment, record the intent
- The webhook’s HMAC-SHA256 signature is verified, and its body is treated as a hint: the API re-fetches the payment (or the subscription’s executions) from dLocal before acting on it.
- Each delivery is keyed on payment id + status, so a retried webhook is a no-op.
- In one PostgreSQL transaction, the API applies the payment and inserts a
billing_outboxrow carrying a snapshot of the customer’s fiscal data. Either both commit or neither does.
Step 2: relay to SQS
A cron relay, running under a job lease so only one instance publishes at a time, picks up to 100 unpublished outbox rows per run, sends them to the billing-events queue with SendMessageBatch and marks them published. Failures are retried with backoff, and a row that fails 10 times is parked for inspection instead of blocking the rest. The same relay code ships the notification events.
Step 3: record first, emit later
The NestJS invoicing service separates consuming from emitting:
- The consumer only records a
fiscal_documentsrow, withON CONFLICT (source_kind, source_ref, kind) DO NOTHING, and deletes the SQS message only after that succeeds. A redelivered event changes nothing. - The emission loop claims due documents with
FOR UPDATE SKIP LOCKEDand a lease token, so instances never step on each other, and a result is only saved while the claim is still held.
Never invoicing twice
The hard case is a send whose outcome is unknown: the request may have reached InvoiCy before the connection dropped. Every document has a deterministic reference, and every attempt begins by asking InvoiCy what it already holds under it:
- Exactly one CFE: adopt its state and never resend.
- Several, or one of another type: mark the document
needs_attentionfor a human. - Outcome unknown: wait a settle window, then look up again before any resend.
Once sent, a CFE still pending at DGI is re-checked with backoff, from 60 seconds up to an hour. Small e-Tickets are only reported in DGI’s daily summary, so after 48 polls without an individual answer they are marked issued.
Safety nets
A reconcile job re-enqueues paid orders without an invoice and refunds without a credit note, which is harmless because recording is idempotent. It also requests an audit that compares the CFEs InvoiCy holds with fiscal_documents and flags any difference.
Why it works
Taking a payment only depends on PostgreSQL being up. Every later hop is asynchronous and idempotent, so InvoiCy can be down, the relay can lag and the invoicing service can be redeployed without losing a document or emitting one twice.