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:
Duro-Signature: t=1709913600,v1=<hex hmac-sha256>
signed payload = `${t}.${rawBody}`
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:
offsets = [1m, 5m, 30m, 2h, 6h, 24h]   // 6 attempts
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. Next: the queue & worker topology that runs all of this.