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

# Webhooks

> Let a third party start a run, and let a finished run tell the outside world - verified inbound POSTs, signed outbound ones, one durable delivery log.

Webhooks are two halves of one feature. An inbound **source** turns a signature-verified external POST
into a Duraton event. An outbound **endpoint** sends a signed POST when a run reaches a lifecycle
transition; `ctx.webhook.send` sends one from workflow code. Both directions sign with the same
HMAC-SHA256 scheme, and every outbound send lands in a durable delivery + attempt log.

Sources and endpoints are config-as-data: create them in the console's **Webhooks** view or over the
[webhooks API](/reference/api/webhooks) - both write the same rows.

## Inbound sources

A source maps a **receive URL** onto an event. You choose the event; Duraton issues the URL and the
signing secret, and returns both once:

```ts theme={null}
const source = await duraton.webhooks.sources.create({
  name: "Stripe",
  app: "shop",
  eventName: "payment.received",
});

console.log(source.receiveUrl); // https://webhooks.duraton.dev/9f3c8a2b... - give this to your provider
console.log(source.secret);     // shown once - store it now
```

The receive URL is not an input: Duraton generates a 128-bit random token for it
([W3C capability-URL guidance](https://www.w3.org/2001/tag/doc/capability-urls/) asks for 120+ bits), so
it is unguessable and immutable after create. The URL is **public** - an external caller carries no
Duraton API key - so the source is the authority: Duraton looks it up by that token, verifies the
signature with the source's own secret, and takes the project and event mapping from it.

```sh theme={null}
# what your provider does, signed with the source's secret
curl -X POST "https://webhooks.duraton.dev/9f3c8a2b..." \
  -H 'content-type: application/json' \
  -H 'x-duraton-signature: t=1752400000&s=<hmacHex>' \
  -d '{ "amount": 4200, "currency": "usd" }'
```

A verified JSON body becomes the event payload and is ingested as `payment.received`, triggering whatever
workflows subscribe to it.

| Response | When                                                                                                                                          |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `202`    | Verified. The event is ingested.                                                                                                              |
| `400`    | The body is not JSON, or could not be read.                                                                                                   |
| `401`    | The signature does not verify. The body carries a `reason` naming which check failed (see [Troubleshooting a `401`](#troubleshooting-a-401)). |
| `404`    | No source matches the token.                                                                                                                  |
| `413`    | The body is over the size limit.                                                                                                              |

Only `202` starts a run.

### Supported signature schemes

A source carries a **signature scheme** and a **signing secret**, and verifies every inbound POST
against them. Duraton looks the source up by its receive-URL token, then checks the request with that
source's scheme. The default is Duraton's own HMAC scheme; a provider preset lets a source accept a
POST signed the way that provider already signs it, so you can point the provider straight at the
receive URL with no translation layer.

| Scheme                  | Signature header                                                        | How the body is signed                                                                                                                                                                                                                |
| ----------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hmac_sha256` (default) | `X-Duraton-Signature: t=<unix>&s=<hmacHex>`                             | HMAC-SHA256 over `` `${t}.${rawBody}` ``, hex-encoded, with the source's secret.                                                                                                                                                      |
| `stripe`                | `Stripe-Signature: t=<unix>,v1=<hmacHex>`                               | HMAC-SHA256 over `` `${t}.${rawBody}` ``, hex-encoded, with the source's secret; several `v1=` values are accepted so a rolled secret keeps verifying.                                                                                |
| `github`                | `X-Hub-Signature-256: sha256=<hmacHex>`                                 | HMAC-SHA256 over the raw body, hex-encoded, with the source's secret. No timestamp in the scheme, so there is no skew check.                                                                                                          |
| `standard_webhooks`     | `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<hmacBase64>` | HMAC-SHA256 over `` `${id}.${timestamp}.${rawBody}` ``, base64-encoded, with the source's secret (conventionally a `whsec_`-prefixed base64 key); several space-separated signatures are accepted so a rolled secret keeps verifying. |

Every timestamped scheme (`hmac_sha256`, `stripe`, `standard_webhooks`) rejects a timestamp more than
**5 minutes** from Duraton's clock; `github` carries no timestamp, so it has no skew check. All schemes
compare the signature in constant time. New sources use `hmac_sha256` unless you set `scheme`. For a
provider scheme, supply the provider's own signing secret as `secret` instead of using the generated one:

```ts theme={null}
const source = await duraton.webhooks.sources.create({
  name: "Stripe",
  app: "shop",
  eventName: "payment.received",
  scheme: "stripe",
  secret: process.env.STRIPE_WEBHOOK_SECRET, // Stripe's signing secret, not a generated one
});
```

### Troubleshooting a `401`

An inbound POST whose signature does not verify is rejected with `401`, and the JSON body names which
check failed in a `reason` field so you can go straight to the cause:

```json theme={null}
{ "error": "signature verification failed", "reason": "timestamp_out_of_tolerance" }
```

| `reason`                     | What failed                                                       | Where to look                                                                                                  |
| ---------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `missing_signature`          | The request carried no signature header.                          | Send the scheme's header (`X-Duraton-Signature`, or `Stripe-Signature` for the `stripe` scheme).               |
| `malformed_signature`        | The signature header was present but could not be parsed.         | Match the header format exactly - `t=<unix>&s=<hex>` for the default scheme, `t=<unix>,v1=<hex>` for `stripe`. |
| `timestamp_out_of_tolerance` | The signed timestamp is more than 5 minutes from Duraton's clock. | Sign with a current timestamp and keep the sender's clock in sync.                                             |
| `signature_mismatch`         | The header parsed, but the signature did not match the body.      | Wrong secret, or the body was altered in transit - work through the checks below.                              |

The signature is over the **exact bytes on the wire**, so a signature that looks right can still fail.
For a `signature_mismatch`, check, in order:

* **Sign the raw body, byte for byte.** HMAC the exact bytes you transmit - never a re-serialized,
  re-formatted, or pretty-printed copy, and watch for a **trailing newline** a shell or client adds
  (`--data-binary` over `--data`, no `echo`). One extra byte changes the hash. This is the most common
  cause of "I signed it exactly per the docs but still get a 401".
* **Timestamp within 5 minutes of Duraton's clock.** The `t` you sign must match the `t` in the header,
  and both must be current - a stale or skewed clock is rejected the same way a bad signature is.
* **Exact secret.** For a provider scheme, HMAC with the provider's own signing secret (Stripe's
  `whsec_...`), not a generated one; for the default scheme, the source's secret.
* **Lowercase hex.** The signature is hex-encoded (64 characters for SHA-256).

To rule out the signature itself, sign the same body with the default `hmac_sha256` scheme against a
source **that has a secret set** and confirm it verifies - a source with no secret accepts anything,
so it is not a valid control.

### Deduplicating deliveries

A source takes an optional `dedupeKey`: a dotted path into the inbound payload used to drop duplicate
deliveries. When set, Duraton reads the value at that path on each verified delivery; a repeat whose
value has already been seen within the dedupe window is accepted (still `202`) but produces **no**
event. Providers that retry the same delivery - Stripe resends an event until you `2xx` it - dedupe on
the provider's own event id:

```ts theme={null}
const source = await duraton.webhooks.sources.create({
  name: "Stripe",
  app: "shop",
  eventName: "payment.received",
  scheme: "stripe",
  secret: process.env.STRIPE_WEBHOOK_SECRET,
  dedupeKey: "id", // Stripe's event id: `evt_...`
});
```

<Warning>
  If the field at `dedupeKey` is absent from a delivery, that delivery is **not** deduplicated - it still
  produces an event. A wrong path never silently drops every delivery: it fails open, one event per
  delivery, as if no `dedupeKey` were set.
</Warning>

### The inbound delivery log

Every POST to a source's receive URL is recorded in an **inbound delivery log**, alongside its
admission outcome - whether or not it became an event. This is the received-side counterpart to the
[outbound delivery log](#delivery-retries-and-the-attempt-log): outbound rows are the POSTs Duraton
*sends*; inbound rows are the POSTs a source *receives*. A previously verified delivery can be
**replayed** to re-ingest its stored body - inbound deliveries are *replayed* (re-injected into the
pipeline), while outbound endpoint deliveries are *redelivered* (sent to the endpoint again).

Each delivery carries a `status` - the admission outcome of that POST:

| `status`        | Meaning                                                                                                                                                                                                                                  |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ingested`      | Verified and emitted an event. See the delivery's `eventId`, and its `runId` if a run started.                                                                                                                                           |
| `deduped`       | Verified, but dropped by the source's [`dedupeKey`](#deduplicating-deliveries), so no event was produced.                                                                                                                                |
| `unauthorized`  | The signature did not verify. A `failureReason` names the failed check - the same values as the [`401` reasons](#troubleshooting-a-401): `missing_signature`, `malformed_signature`, `timestamp_out_of_tolerance`, `signature_mismatch`. |
| `invalid`       | Verified, but the body was not JSON.                                                                                                                                                                                                     |
| `too_large`     | The body was over the size limit.                                                                                                                                                                                                        |
| `misconfigured` | The source's stored secret could not be read, so the POST could not be verified.                                                                                                                                                         |

### Attempts and replay

Like an outbound delivery, an inbound delivery keeps an **append-only attempt log**. Attempt 1 is the
original ingest (`trigger: initial`); each manual **replay** appends another attempt (`trigger: replay`)
that records the actor who triggered it - so a delivery's full history is visible, not only its latest
outcome.

The delivery's top-level `status` is the **first** admission outcome and stays frozen: a replay never
rewrites it, and each replay's outcome lives on its own attempt row instead. The delivery row and each
attempt also carry `eventId` (the emitted event) and `runId` (a run it woke), letting you pivot from a
delivery to its event to a run; both are omitted when nothing was produced - a deduped or rejected post,
or an ingest that matched no workflow.

Replay re-ingests the **stored body** through the source's *current* dedupe key and event mapping:

* **Only a delivery that originally verified** (`ingested` or `deduped`) is replayable. A rejected
  delivery (`unauthorized`, `invalid`, `too_large`, `misconfigured`) stored no verified body, so there is
  nothing legitimate to re-ingest.
* **Replay does not re-check the signature.** The delivery was already verified when it arrived, and its
  signed timestamp would now be far outside the tolerance window. Replay re-runs the *pipeline* - the
  source's current `dedupeKey` and event mapping - over the stored body, not the signature check.
* **Dedupe still applies.** Within a dedupe window a replay dedupes exactly as a real provider
  redelivery would, so replaying a source that has a `dedupeKey` is idempotent. Without a `dedupeKey`,
  each replay starts a fresh run.

<Warning>
  **A replay re-triggers downstream workflow effects.** Re-ingesting a delivery emits its event again, so
  every workflow subscribed to that event runs again - unless the source's `dedupeKey` dedupes the
  replay. Replay a delivery whose side effects you are willing to repeat, or rely on a `dedupeKey` to
  make it a no-op.
</Warning>

Inspect and replay a delivery over the SDK - see the
[webhooks API](/reference/api/webhooks#the-inbound-delivery-log) for the REST shape:

```ts theme={null}
const { deliveries } = await duraton.webhooks.sourceDeliveries.list({ status: "unauthorized" });

const detail = await duraton.webhooks.sourceDeliveries.get(deliveries[0].id);
for (const a of detail.attempts) console.log(a.trigger, a.status, a.actor);

// re-ingest a previously verified delivery's stored body; the result reports what it produced
const result = await duraton.webhooks.sourceDeliveries.replay(detail.id);
console.log(result.status, result.eventId, result.runId); // "ingested" | "deduped"; eventId/runId set only when it ingested
```

The inbound delivery log - the receipt, the stored body, and its attempts - is bounded by the same
retention sweep that purges runs, events, and outbound deliveries: it is kept only while retention is
configured, so received payloads are not held indefinitely.

## Outbound subscriptions

An endpoint subscribes a URL to one or more run lifecycle kinds. When a matching transition happens,
Duraton enqueues one delivery per subscribed endpoint.

```ts theme={null}
const endpoint = await duraton.webhooks.endpoints.create({
  name: "Acme prod",
  app: "shop", // omit to subscribe to every app in the project
  url: "https://hooks.example.com/duraton",
  eventKinds: ["run.failed", "run.succeeded"],
});

console.log(endpoint.secret); // shown once - this is what signs the deliveries
```

The lifecycle kinds are `run.succeeded`, `run.failed`, `run.cancelled`, and `step.failed`. The delivery
payload is a bounded run summary - `runId`, `workflowName`, `app`, `status`, and the error or result -
not the full step set.

## `ctx.webhook.send`

A workflow can POST to a URL directly. The send is a **durable step**: it is recorded like any other, so
a replayed pass never re-sends it. The first argument is its step id, which is what makes the replay
deterministic.

```ts theme={null}
import { workflow } from "@duraton/sdk";

export const orderShip = workflow({
  name: "order.ship",
  handler: async (ctx) => {
    await ctx.webhook.send("notify-partner", {
      url: "https://partner.example.com/shipments",
      data: { orderId: ctx.event.data.orderId, shipped: true },
    });
  },
});
```

<Warning>
  A `ctx.webhook.send` carries no endpoint, so it has no secret to sign with and the delivery goes out
  **unsigned**. Subscribe a registered endpoint when the receiver must verify a signature.
</Warning>

## Signing and verification

Every delivery POST carries this header set:

| Header                | Value                                              |
| --------------------- | -------------------------------------------------- |
| `Content-Type`        | `application/json`                                 |
| `User-Agent`          | `Duraton-Webhooks/<n>`                             |
| `X-Duraton-Id`        | The delivery id.                                   |
| `X-Duraton-Timestamp` | The unix send time, matching the signature's `t`.  |
| `X-Duraton-Event`     | The lifecycle kind, e.g. `run.failed`.             |
| `X-Duraton-Signature` | `t=<unix>&s=<hmacHex>` - endpoint deliveries only. |

The HMAC-SHA256 is computed over `` `${t}.${rawBody}` `` with the endpoint's secret and hex-encoded.
Verify it over the **raw** body, before any JSON parse, and compare in constant time:

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

const MAX_SKEW_SECONDS = 300;

export function verify(rawBody: string, header: string, secret: string): boolean {
  const params = new URLSearchParams(header);           // t=<unix>&s=<hmacHex>
  const t = Number(params.get("t"));
  const sig = params.get("s") ?? "";
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > MAX_SKEW_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
```

Duraton rejects an inbound signature more than **5 minutes** from its own clock, and this is the same
scheme an inbound source verifies - so a Duraton endpoint can deliver into a Duraton source end to end.

## Delivery, retries, and the attempt log

Both triggers write the same delivery row and ride one delivery loop: Duraton signs (for endpoint
deliveries), POSTs, records the attempt, and classifies the response.

| Response                                                         | Outcome                                                                                |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `2xx`                                                            | `succeeded`.                                                                           |
| `5xx`, `429`, or a transport error (timeout, connection refused) | Retried: `failed` between attempts, `exhausted` once the row's `maxAttempts` is spent. |
| Any other `4xx`                                                  | `dead` immediately - a non-retryable client error, e.g. a bad URL.                     |

Outbound retries back off **exponentially**: 1s before the second attempt, doubling each time, capped at
30s. This is a different subsystem from [step retries](/core/retries), which wait a
**fixed** delay between attempts - a workflow's `retry` policy has no effect on webhook delivery, and a
delivery's `maxAttempts` has none on a step.

Every POST appends a row to the attempt log - status code, response snippet, error, duration, and the
exact request and response headers - so a failing delivery is debuggable from its full history, not only
its final state:

```ts theme={null}
const { deliveries } = await duraton.webhooks.deliveries.list({ status: "exhausted" });

const detail = await duraton.webhooks.deliveries.get(deliveries[0].id);
for (const a of detail.attempts) console.log(a.statusCode, a.durationMs, a.error);

await duraton.webhooks.deliveries.redeliver(detail.id); // re-queue it for another attempt
```

## Egress safety

Outbound delivery is a server-side request to a URL you supply, so Duraton guards against SSRF: loopback,
private (RFC-1918), link-local (including the `169.254.169.254` cloud-metadata address), and unspecified
addresses are **blocked**, checked at dial time against the resolved IP so a DNS rebind cannot slip past.
Point endpoints at publicly reachable URLs.
