> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Event-sourced, signed, retried, and SSRF-guarded delivery — driven off the same events table the dashboard reads.

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.

<Note>
  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.
</Note>

## Fan-out from the events table

```mermaid theme={null}
flowchart TB
    SVC["any service<br/>events.record('subscription_payment_success', …)"] --> EV["events table<br/>(dispatchedAt = null)"]
    SCAN["webhook scan · every 15s"] --> EV
    SCAN --> FAN["for each undispatched event:<br/>find enabled endpoints<br/>matching the event type"]
    FAN --> DEL["create WebhookDelivery (pending)<br/>per endpoint"]
    FAN --> MARK["mark event dispatchedAt"]
    SCAN --> RETRY["also: re-enqueue deliveries<br/>where status=pending and<br/>nextRetryAt ≤ now"]
    DEL & RETRY --> JOB["deliver job"]
    JOB --> POST["signed POST to endpoint.url"]
```

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}`
```

```mermaid theme={null}
flowchart LR
    BODY["raw JSON body"] --> SIGN["HMAC-SHA256(secret, t + '.' + body)"]
    SIGN --> HDR["Duro-Signature: t=…,v1=…"]
    HDR --> MERCH["merchant recomputes,<br/>timing-safe compares,<br/>checks t within tolerance"]
```

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:

```mermaid theme={null}
flowchart TD
    CREATE["save endpoint (dashboard)"] --> FMT{"WebhookUrlGuard.isFormatSafe"}
    FMT -->|"not https, or<br/>private/loopback/<br/>metadata host"| REJ["422 rejected"]
    FMT -->|"ok"| SAVE["saved"]
    DELIVER["at delivery time"] --> DNS{"assertResolvable:<br/>DNS-resolve host,<br/>any private IP?"}
    DNS -->|"yes"| BLOCK["fail terminally:<br/>'blocked destination'"]
    DNS -->|"no"| SENT["POST"]
```

* **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](/security/security-model).

## 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](/api-reference/webhook-events).

Next: the [queue & worker topology](/platform/queues-and-workers) that runs all of this.
