Skip to main content
Webhooks are how a merchant’s server learns what happened without polling. Duro’s delivery system is event-sourced: every meaningful change writes a row to the events table, and a worker fans those rows out to subscribed endpoints with signing and retries.
Endpoints are subscribed in the dashboard (Developers, Webhooks), not through the public API, so a leaked sk_ key can’t quietly redirect a merchant’s event stream. The signing secret is shown once on creation.

Fan-out from the events table

Decoupling delivery from the event source means a merchant’s slow or down endpoint never blocks the operation that produced the event. The producer just appends a row; the worker does the rest, exactly once per (event, endpoint) thanks to a dispatchedAt marker and jobId dedup.

Signing: HMAC over a timestamped payload

Every delivery carries a Duro-Signature header the merchant verifies:
The secret is shown once at endpoint creation. Verification is a timing-safe compare with a timestamp tolerance to reject replays. The signer (WebhookSigner in @duro/crypto) is the same primitive used for the docs’ verification guide.

Retries with exponential backoff

A non-2xx (or a network error) schedules a retry:
The delivery stays pending with a future nextRetryAt until it succeeds (→ delivered) or exhausts the offsets (→ failed). Every attempt records the response status and a truncated body, visible in the dashboard’s delivery inspector, which also offers a one-click replay.

SSRF guard: the security teeth

A webhook URL is attacker-controlled (the merchant types it), and the response body is stored and shown back in the inspector. That’s a textbook SSRF-to-exfiltration vector: point the URL at http://169.254.169.254/… and read cloud-metadata credentials out of the delivery log. Duro blocks it at two layers:
  • When saved in the dashboard: require https, reject loopback, RFC-1918 private ranges, link-local 169.254/16, CGNAT 100.64/10, ULA IPv6, and hostnames like localhost, *.internal, metadata.google.internal, for IPv4 and (bracketed) IPv6.
  • At delivery: re-resolve the host’s DNS and reject if it now points at a private IP, defeating DNS-rebinding, where a public hostname flips to an internal address after it passed the creation check.
This is covered by unit tests and documented as a HIGH-severity finding fixed during the security audit.

The event catalog

Event names are canonical and consistent: one underscore convention, defined once in DuroEvent and emitted by the state machine and services, so a subscription to subscription_payment_success actually matches what’s sent. The full catalog with payloads is in the API reference.

Inbound: how Duro is notified by Nomba

The system above is outbound (Duro to merchant). There’s also an inbound leg (Nomba to Duro) that funds wallets and settles checkouts. Nomba posts a signed webhook to a Duro endpoint; the worker verifies it, then routes on the transaction type:
  • online_checkout settles the checkout, creating the subscription and paid invoice, or (for a topup session) crediting the wallet and saving the card.
  • vact_transfer correlates a bank transfer to a virtual account by its accountRef and auto-credits the wallet.
Both are keyed idempotently to the Nomba transaction id, so a redelivered inbound webhook grants value exactly once. Nomba’s signature is an HMAC over a colon-joined set of fields (not the raw body), verified timing-safe, and mirrored to the backend integration docs. Next: the queue & worker topology that runs all of this.