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
PaymentsServicecallsWebhooksService.schedule()inside the payment's database transaction.- One or more
webhook_eventsrows are committed with the payment. Scenario delay, duplicate count and invalid-signature intent are stored on those rows. OutboxDispatcherclaims due rows withFOR UPDATE SKIP LOCKED, stampsqueued_atand hands them to the transport. It does not run at all under thepostgresdriver, which reads those rows itself: two claimers on one table is a race, not a redundancy.- The worker reloads the durable row, asks the provider plugin to sign it and POSTs to the application's callback.
- Each try creates a
webhook_attempt; the event becomesdeliveredorfailed.
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:orhttps: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.