Skip to content
Payment Emulator LabDocumentationOpen console

Runtime

Payment webhooks: emission and delivery

Webhook delivery uses a transactional outbox in PostgreSQL and a configurabletransport. The API writes obligations; only the worker sends HTTP.

4 min read

Índice / Index · Español

Webhook delivery uses a transactional outbox in PostgreSQL and a configurable transport. The API writes obligations; only the worker sends HTTP.

WEBHOOK_QUEUE_DRIVER Jobs live in Needs running
postgres (default) webhook_events itself nothing further
mongo a webhook_jobs document mongod, standalone
bullmq Redis Redis

No driver holds payment truth — an event id, a try count and a time, and nothing else. See ADR 011.

#Lifecycle

  1. PaymentsService calls WebhooksService.schedule() inside the payment's database transaction.
  2. One or more webhook_events rows are committed with the payment. Scenario delay, duplicate count and invalid-signature intent are stored on those rows.
  3. OutboxDispatcher claims due rows with FOR UPDATE SKIP LOCKED, stamps queued_at and hands them to the transport. It does not run at all under the postgres driver, which reads those rows itself: two claimers on one table is a race, not a redundancy.
  4. The worker reloads the durable row, asks the provider plugin to sign it and POSTs to the application's callback.
  5. Each try creates a webhook_attempt; the event becomes delivered or failed.

The row is the commitment and the queue is replaceable transport — literally so since ADR 011. If the transport is temporarily unavailable, the API can still commit the payment and outbox row. The stale sweep later recreates the missing job.

#Delivery semantics

Delivery is at-least-once. Four attempts with exponential backoff starting at 250 ms, declared once in RETRY and applied by every driver — BullMQ internally, the other two by arithmetic in their claim loop. test/integration/webhook-queue.spec.ts runs one contract suite against each configured driver, which is what keeps them from drifting apart. A crash between claiming and enqueueing is repaired by the stale outbox sweep; a scenario may also create duplicates intentionally. A receiver must therefore process provider event identifiers idempotently.

#Provider responsibility

The plugin supplies:

  • which canonical states produce a topic;
  • provider-shaped payload;
  • signing headers and manifest;
  • verification rules including timestamp tolerance.

Mercado Pago and Stripe formats are documented in their fidelity files. Static conformance requires a total, reproducible scheme, while official SDK tests verify the portions their libraries expose.

#Network safety

Before sending, apps/api/src/worker.ts requires:

  • RUNTIME_OUTBOUND_ENABLED=true;
  • an http: or https: callback;
  • a hostname that resolves to a public address.

The last one is the one that matters, and it is checked by address rather than by name: the webhook URL is chosen by whoever registers the application, so a name proves nothing — anyone can point their own domain at 192.168.1.50. Without that check the emulator is a remote control for making requests from inside the network it is deployed in, and the status and duration of every attempt are visible to whoever registered the application.

The whole IANA special-purpose space is refused: private, loopback, link-local — including the 169.254.169.254 cloud metadata address — carrier NAT, multicast and reserved, plus their IPv6 equivalents and IPv4-mapped forms. Every address DNS returns is checked, not the first: a name answering with one public and one private address is the oldest way around a check like this.

WEBHOOK_ALLOWED_HOSTS is the exception to that rule rather than the rule itself: hosts allowed in spite of resolving somewhere internal. A public customer domain needs no entry; localhost does, and has one by default so a local install keeps working.

What this does not close is DNS rebinding: the name is resolved here and resolved again by fetch, and a domain with a one-second TTL could answer differently the second time. Closing it means connecting to the address that was checked, which costs certificate verification unless done very carefully. The window is narrow and needs the attacker to win a race; it is written down here rather than left to be discovered.

Provider production calls do not exist. Outbound HTTP is only webhook delivery.

#Inspecting delivery

The control plane exposes webhook events and attempts through apps/api/src/modules/webhooks/webhooks.controller.ts; the console renders them for authorized admins/merchants. Attempts record response status, duration, error and the signature manifest—not a second copy of the payment truth.

#Known scope gap

The event listing is filtered by applicationId, but the current GET /applications/:applicationId/webhooks/:eventId/attempts handler queries the event ID without also constraining it to the application in the path. The route still requires access to an application, but it does not prove that the event belongs to that same application. Keep the emulator on a trusted development network until that check and a cross-tenant regression test are added.

See Critical request flows for the sequence diagram and Troubleshooting when events do not leave the outbox.

Payment Emulator Lab · RonuSoftwareMIT