Skip to content
All notes

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.

Event-driven
Transactional outbox
AWS SQS
Idempotency

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.

dLocal webhook to the api, one PostgreSQL transaction with a billing_outbox row, a cron relay to SQS, then the invoicing service records a fiscal document and an emission loop sends it to InvoiCy and DGI. A reconcile job and a CFE audit act as safety nets.
Three hops, each asynchronous and idempotent. Click to open at full resolution.

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_outbox row 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_documents row, with ON 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 LOCKED and a lease token, so instances never step on each other, and a result is only saved while the claim is still held.
Fiscal document lifecycle: pending, claim, look up the reference at InvoiCy, then adopt an existing CFE, flag duplicates as needs_attention, or send. Sends end authorized, pending_dgi, rejected or unknown; pending and unknown documents loop back to claim.
Every attempt starts by asking InvoiCy what it already holds.

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_attention for 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.

Instant Empleos