> ## 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 API

> Prove a delivery happened and fix it when it did not: read the inbound and outbound delivery logs, manage source and endpoint configs, redeliver, or replay.

The webhooks API backs the console's Webhooks view. It exposes both the outbound delivery log (with
per-attempt history) and the inbound **source** delivery log, lets you **redeliver** an outbound
delivery or **replay** an inbound one, and provides full CRUD for the inbound **source** and outbound
**endpoint** configs. Signing secrets are **shown once** when a config is created or rotated and are
never returned by any read.

The config-write routes (`POST`/`PATCH`/`DELETE` on endpoints and sources) and the redeliver and replay
routes require a **secret** API key. Duraton seals every signing secret at rest for you.

| Method + path                                 | Purpose                                                                             |
| --------------------------------------------- | ----------------------------------------------------------------------------------- |
| `GET /webhook-deliveries`                     | A page of outbound deliveries, newest first, plus an `X-Next-Cursor` header.        |
| `GET /webhook-deliveries/{id}`                | One delivery with its full `attempts` log.                                          |
| `POST /webhook-deliveries/{id}/redeliver`     | Re-queue a delivery for an immediate fresh attempt. Returns `204`.                  |
| `GET /webhook-source-deliveries`              | A page of inbound source deliveries, newest first, plus an `X-Next-Cursor` header.  |
| `GET /webhook-source-deliveries/{id}`         | One inbound delivery with its request headers, stored body, and `attempts` log.     |
| `POST /webhook-source-deliveries/{id}/replay` | Re-ingest a verified delivery's stored body. Returns `200` with the replay outcome. |
| `GET /webhook-endpoints`                      | The outbound subscription configs (no secrets).                                     |
| `GET /webhook-endpoints/stats`                | Per-endpoint delivery health over a window.                                         |
| `GET /webhook-endpoints/{id}`                 | One outbound endpoint config (no secret).                                           |
| `POST /webhook-endpoints`                     | Create an outbound endpoint; returns `201` with the signing secret once.            |
| `PATCH /webhook-endpoints/{id}`               | Edit an endpoint; optionally rotate its secret. Returns `200`.                      |
| `DELETE /webhook-endpoints/{id}`              | Delete an endpoint. Returns `204`.                                                  |
| `GET /webhook-sources`                        | The inbound source configs (no secrets).                                            |
| `GET /webhook-sources/{id}`                   | One inbound source config (no secret).                                              |
| `POST /webhook-sources`                       | Create an inbound source; returns `201` with the signing secret once.               |
| `PATCH /webhook-sources/{id}`                 | Edit a source; optionally rotate its secret. Returns `200`.                         |
| `DELETE /webhook-sources/{id}`                | Delete a source. Returns `204`.                                                     |

See the [webhooks guide](/integrations/webhooks) for what produces these rows. Endpoints and
sources can also be managed from the console's Webhooks view; both paths write the same rows.

## Listing deliveries

`GET /webhook-deliveries` accepts these query parameters:

| Param    | Meaning                                                                                   | Default  |
| -------- | ----------------------------------------------------------------------------------------- | -------- |
| `status` | One delivery status: `pending`, `delivering`, `succeeded`, `failed`, `exhausted`, `dead`. | all      |
| `app`    | Restrict to deliveries for one app's runs.                                                | all apps |
| `limit`  | Page size, `1`-`200`.                                                                     | `30`     |
| `cursor` | Opaque keyset cursor from a previous page's `X-Next-Cursor`.                              | none     |

Paging is **keyset** over `(createdAt, id)` - the same model as [`GET /runs`](/reference/api/runs#keyset-pagination):
the response carries up to `limit` deliveries plus an `X-Next-Cursor` header when more exist; the header is
absent on the last page.

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={null}
    import { createClient } from "@duraton/sdk/client";

    const duraton = createClient({ url: process.env.DURATON_URL! });
    const page = await duraton.webhooks.deliveries.list({ app: "shop", status: "exhausted", limit: 20 });
    page.deliveries;  // WebhookDelivery[]
    page.nextCursor;  // pass back as { cursor } for the next page
    ```
  </Tab>

  <Tab title="REST API">
    ```sh theme={null}
    curl "$DURATON_URL/webhook-deliveries?app=shop&status=exhausted&limit=20"
    ```
  </Tab>
</Tabs>

Each delivery has:

| Field                          | Meaning                                                                                                                           |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `id`                           | Delivery id.                                                                                                                      |
| `app`                          | The app whose run produced the delivery.                                                                                          |
| `endpointId`                   | The subscribed endpoint, or absent for a `ctx.webhook.send`.                                                                      |
| `url`                          | The destination Duraton POSTs to.                                                                                                 |
| `eventKind`                    | `run.succeeded`, `run.failed`, `run.cancelled`, `step.succeeded`, `step.failed`, `step.skipped`, or `custom`.                     |
| `sourceRunId`                  | The run whose lifecycle produced it (absent for a custom send).                                                                   |
| `payload`                      | The body Duraton sends.                                                                                                           |
| `status`                       | `pending`, `delivering`, `succeeded`, `failed` (awaiting retry), `exhausted` (retries spent), or `dead` (non-retryable response). |
| `attemptCount` / `maxAttempts` | Attempts made / allowed.                                                                                                          |
| `lastStatusCode`               | The latest attempt's HTTP status (absent until a code is recorded).                                                               |
| `nextAttemptAt`                | When Duraton next retries (while `failed`).                                                                                       |
| `createdAt` / `updatedAt`      | RFC3339 timestamps.                                                                                                               |

## One delivery and its attempts

`GET /webhook-deliveries/{id}` returns the delivery above plus an `attempts` array - the append-only log
of every POST Duraton made, which is the per-attempt detail the console's delivery inspector shows.
A wrong-project id reads back as `404`.

```json theme={null}
{
  "id": "9f2b...", "app": "shop", "url": "https://hooks.example/sink",
  "eventKind": "run.failed", "status": "exhausted", "attemptCount": 5, "maxAttempts": 5,
  "attempts": [
    { "id": "a1", "attempt": 1, "outcome": "http_error", "statusCode": 500, "responseSnippet": "boom", "durationMs": 42,
      "requestHeaders": { "X-Duraton-Event": "run.failed", "X-Duraton-Signature": "t=1750000000&s=..." },
      "responseHeaders": { "Content-Type": "text/plain" }, "createdAt": "2026-06-25T10:00:00Z" },
    { "id": "a2", "attempt": 2, "outcome": "timeout", "durationMs": 10000, "createdAt": "2026-06-25T10:00:11Z" }
  ]
}
```

| Attempt field     | Meaning                                                                 |
| ----------------- | ----------------------------------------------------------------------- |
| `attempt`         | 1-based attempt number.                                                 |
| `outcome`         | `succeeded`, `http_error`, `timeout`, `connection_error`, or `skipped`. |
| `statusCode`      | The HTTP status, when the partner responded.                            |
| `responseSnippet` | A bounded prefix of the response body, for debugging.                   |
| `error`           | The transport error, when there was no response.                        |
| `durationMs`      | How long the attempt took.                                              |
| `requestHeaders`  | The exact signed header set sent (identifiers + signature).             |
| `responseHeaders` | The headers the endpoint returned, absent when there was no response.   |

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={null}
    const detail = await duraton.webhooks.deliveries.get("<id>");
    detail.attempts; // WebhookDeliveryAttempt[]
    ```
  </Tab>

  <Tab title="REST API">
    ```sh theme={null}
    curl "$DURATON_URL/webhook-deliveries/<id>"
    ```
  </Tab>
</Tabs>

## Redelivering a delivery

`POST /webhook-deliveries/{id}/redeliver` re-queues a delivery for an immediate fresh attempt and returns
`204`. It keeps the existing attempt log and grants a new retry budget, so Duraton signs and POSTs it
again. Use it to re-send a delivery that `exhausted` its retries, `dead`-lettered on a non-retryable
response, or already `succeeded` (a manual re-send).

A delivery that is currently in flight (`delivering`) cannot be redelivered - the call returns `409` so a
manual redeliver never races an in-flight attempt. A missing or wrong-project id returns `404`.

```sh theme={null}
curl -X POST "$DURATON_URL/webhook-deliveries/<id>/redeliver"
```

## The inbound delivery log

Every POST to a [source's receive URL](/integrations/webhooks#inbound-sources) is recorded as an
inbound **source delivery**, alongside its admission outcome - the received-side counterpart to the
outbound delivery log above. A verified delivery can be **replayed** to re-ingest its stored body.

`GET /webhook-source-deliveries` accepts these query parameters, all optional; omit them for a
workspace-wide listing:

| Param    | Meaning                                                                                                | Default     |
| -------- | ------------------------------------------------------------------------------------------------------ | ----------- |
| `source` | Restrict to one source's deliveries.                                                                   | all sources |
| `status` | One admission outcome: `ingested`, `deduped`, `unauthorized`, `invalid`, `too_large`, `misconfigured`. | all         |
| `limit`  | Page size, `1`-`200`.                                                                                  | `30`        |
| `cursor` | Opaque keyset cursor from a previous page's `X-Next-Cursor`.                                           | none        |

Paging is **keyset**, the same model as `GET /webhook-deliveries`: the response carries up to `limit`
deliveries plus an `X-Next-Cursor` header when more exist.

A `status` outside the accepted set (`ingested`, `deduped`, `unauthorized`, `invalid`, `too_large`,
`misconfigured`) returns `400` naming the accepted values, rather than an empty page - so a typo is a
loud error, not a silently empty result.

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={null}
    const page = await duraton.webhooks.sourceDeliveries.list({ source: "<id>", status: "unauthorized", limit: 20 });
    page.deliveries;  // WebhookSourceDelivery[]
    page.nextCursor;  // pass back as { cursor } for the next page
    ```
  </Tab>

  <Tab title="REST API">
    ```sh theme={null}
    curl "$DURATON_URL/webhook-source-deliveries?source=<id>&status=unauthorized&limit=20"
    ```
  </Tab>
</Tabs>

Each delivery's `status` is the **original** admission outcome of that POST, frozen at ingest - a later
replay does not rewrite it (per-replay outcomes live in [`attempts[]`](#one-inbound-delivery-and-its-attempts)):

| `status`        | Meaning                                                                                                                                                                  |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ingested`      | Verified and emitted an event (`eventId`). A run may have started - see `runId`.                                                                                         |
| `deduped`       | Verified, but dropped by the source's `dedupeKey`, so no event was produced.                                                                                             |
| `unauthorized`  | The signature did not verify. `failureReason` names the failed check: `missing_signature`, `malformed_signature`, `timestamp_out_of_tolerance`, or `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.                                                                                         |

### One inbound delivery and its attempts

`GET /webhook-source-deliveries/{id}` returns the delivery with its request headers, the stored body
(present only for a delivery that verified), and an append-only `attempts` log. Attempt 1 is the initial
ingest (`trigger: initial`); each manual replay appends an attempt (`trigger: replay`) recording the
`actor` who triggered it. A wrong-project id reads back as `404`.

```json theme={null}
{
  "id": "d7a1...", "source": "s_9f3c", "status": "ingested",
  "eventId": "evt_5d1a", "runId": "r_82b4",
  "requestHeaders": { "content-type": "application/json", "x-duraton-signature": "t=1750000000&s=..." },
  "body": "{ \"amount\": 4200, \"currency\": \"usd\" }",
  "attempts": [
    { "id": "a1", "attempt": 1, "trigger": "initial", "status": "ingested", "eventId": "evt_5d1a", "runId": "r_82b4", "createdAt": "2026-06-25T10:00:00Z" },
    { "id": "a2", "attempt": 2, "trigger": "replay", "status": "deduped", "actor": "ops-key", "createdAt": "2026-06-25T11:30:00Z" }
  ]
}
```

The row and each attempt carry `eventId` (the event the admission emitted) and `runId` (a run it woke),
so you can pivot from a delivery to its event to a run. Both are **omitted** when the admission produced
neither - a deduped or rejected post, or an ingest that matched no workflow - as on the replayed attempt
above.

The delivery's top-level `status` is the **original** admission outcome and is frozen: a replay never
rewrites it, so `?status=ingested` still returns a delivery after it has been replayed. Each replay's own
outcome lives only in its `attempts[]` row - the attempt above ingested first, then deduped on replay,
while the delivery stays `ingested`.

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={null}
    const detail = await duraton.webhooks.sourceDeliveries.get("<id>");
    for (const a of detail.attempts) console.log(a.trigger, a.status, a.actor);
    ```
  </Tab>

  <Tab title="REST API">
    ```sh theme={null}
    curl "$DURATON_URL/webhook-source-deliveries/<id>"
    ```
  </Tab>
</Tabs>

### Replaying an inbound delivery

`POST /webhook-source-deliveries/{id}/replay` re-ingests the stored body and returns `200` with the
replay outcome - what re-ingesting the body produced - as `{ deliveryId, status, eventId?, runId? }`. It
requires a **full-access** key. Only a delivery that originally verified (`ingested` or `deduped`) is
replayable - a rejected delivery stored no verified body. The replay outcome is recorded as a new
`attempts[]` row on the delivery; the delivery's top-level `status` stays the original admission outcome.

Replay does **not** re-check the signature (the delivery was verified when it arrived, and its signed
timestamp would now be far outside the tolerance window). It re-runs the source's *current* `dedupeKey`
and event mapping over the stored body, so within a dedupe window a replay dedupes exactly as a real
provider redelivery would. See the [guide](/integrations/webhooks#the-inbound-delivery-log) for the
replay semantics, including that a replay **re-triggers downstream workflow effects**.

| Result field | Meaning                                                                                                         |
| ------------ | --------------------------------------------------------------------------------------------------------------- |
| `deliveryId` | The replayed delivery's id.                                                                                     |
| `status`     | The re-ingest outcome: `ingested` (a new event was emitted) or `deduped` (the source's `dedupeKey` dropped it). |
| `eventId`    | The event the replay emitted - present only when `status` is `ingested`.                                        |
| `runId`      | The run the replay woke - present only when `status` is `ingested`.                                             |

<Tabs>
  <Tab title="TypeScript">
    ```ts theme={null}
    const result = await duraton.webhooks.sourceDeliveries.replay("<id>");
    result.status;  // "ingested" | "deduped"
    result.eventId; // set only when it ingested
    result.runId;   // set only when it ingested
    ```
  </Tab>

  <Tab title="REST API">
    ```sh theme={null}
    curl -X POST "$DURATON_URL/webhook-source-deliveries/<id>/replay" \
      -H 'authorization: Bearer <secret-key>'
    # 200
    # { "deliveryId": "d7a1...", "status": "ingested", "eventId": "evt_7f10", "runId": "r_9c02" }
    ```
  </Tab>
</Tabs>

<Note>
  The inbound source-delivery routes are exposed by the TypeScript SDK's
  `duraton.webhooks.sourceDeliveries` surface (`list`, `listAll`, `get`, `replay`).
</Note>

## Managing endpoints and sources

`GET /webhook-endpoints` and `GET /webhook-sources` return the outbound subscriptions and inbound
sources for the project. Both **omit the signing secret entirely** - it is sealed at rest and never
leaves Duraton in a read.

```ts theme={null}
const endpoints = await duraton.webhooks.endpoints.list(); // WebhookEndpoint[] (name?, url, scheme, eventKinds, enabled)
const sources = await duraton.webhooks.sources.list(); // WebhookSource[] (name?, token, receiveUrl?, eventName, scheme, enabled)
```

### Subscribable event kinds

An endpoint's `eventKinds` is the set of lifecycle kinds it subscribes to. Delivery is
**subscription-gated**: an endpoint only receives a kind it explicitly subscribed to, so a payload never
arrives for a kind you did not ask for. Every kind fires on a **terminal** transition - a run reaching a
final state, or a step settling for the last time.

| Kind             | Fires when                                                                      |
| ---------------- | ------------------------------------------------------------------------------- |
| `run.succeeded`  | A run reached its terminal succeeded state.                                     |
| `run.failed`     | A run reached its terminal failed state.                                        |
| `run.cancelled`  | A run was cancelled.                                                            |
| `step.succeeded` | A step settled successfully - per-step **progress**, not just per-step failure. |
| `step.failed`    | A step settled with a terminal failure.                                         |
| `step.skipped`   | A step was skipped rather than executed.                                        |

The `step.*` kinds are **per-step**: subscribe to them to track a run's progress step by step instead of
waiting for the whole run to finish. They fire only on a step's terminal transition (not on intermediate
retries). `custom` is not subscribable - it is produced by a workflow's `ctx.webhook.send`, not by
subscribing an endpoint.

### Creating

`POST /webhook-endpoints` creates an outbound subscription; `POST /webhook-sources` creates an inbound
source. The response includes the freshly generated `secret` **once** - store it now; it is never
returned again. Supplying your own `secret` adopts it instead of generating one.

```sh theme={null}
# outbound endpoint: deliver run lifecycle events to a URL
curl -X POST "$DURATON_URL/webhook-endpoints" \
  -H 'authorization: Bearer <secret-key>' -H 'content-type: application/json' \
  -d '{ "name": "Acme prod", "url": "https://hooks.example/sink", "eventKinds": ["run.failed", "run.succeeded"] }'

# inbound source: map a verified inbound POST onto a Duraton event
curl -X POST "$DURATON_URL/webhook-sources" \
  -H 'authorization: Bearer <secret-key>' -H 'content-type: application/json' \
  -d '{ "name": "Stripe", "eventName": "stripe.charge" }'
```

| Endpoint body | Meaning                                                                                                                                                                    |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`        | Optional label; the console falls back to the URL when absent.                                                                                                             |
| `app`         | Optional - restrict deliveries to one app; absent means all apps.                                                                                                          |
| `url`         | Required destination.                                                                                                                                                      |
| `eventKinds`  | One or more of the [subscribable event kinds](#subscribable-event-kinds): `run.succeeded`, `run.failed`, `run.cancelled`, `step.succeeded`, `step.failed`, `step.skipped`. |
| `secret`      | Optional - adopt a known secret instead of generating one.                                                                                                                 |

A duplicate endpoint `url` for the same app, or a duplicate endpoint `name` within the project,
returns `409`.

| Source body | Meaning                                                                                                                                                                                                      |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`      | Optional label; the console shows a short form of the receive URL when absent. Unique within the project.                                                                                                    |
| `app`       | Optional - the app the produced event belongs to.                                                                                                                                                            |
| `eventName` | Required - the Duraton event a verified post is mapped to.                                                                                                                                                   |
| `targetApp` | Optional - target a specific app for the produced event.                                                                                                                                                     |
| `dedupeKey` | Optional - a dotted path into the inbound payload; a repeat whose value at it was already seen within the dedupe window is accepted but produces no event. A delivery missing the field is not deduplicated. |
| `secret`    | Optional - adopt a known signing secret instead of generating one.                                                                                                                                           |

The receive URL is **not** an input: Duraton always issues a 128-bit random, unguessable token for it
(immutable after create) and returns the full URL as `receiveUrl` on the created source. A duplicate
source `name` within the project returns `409`.

### Editing, rotating, deleting

`PATCH` accepts a partial body - omitted fields are left unchanged. Set `rotateSecret: true` to issue a
new signing secret; the response then carries the new `secret` once (it is absent on an edit that did not
rotate). `DELETE` removes the config and returns `204`. A wrong-project id returns `404`.

```sh theme={null}
# disable an endpoint and rotate its secret
curl -X PATCH "$DURATON_URL/webhook-endpoints/<id>" \
  -H 'authorization: Bearer <secret-key>' -H 'content-type: application/json' \
  -d '{ "enabled": false, "rotateSecret": true }'

curl -X DELETE "$DURATON_URL/webhook-endpoints/<id>" -H 'authorization: Bearer <secret-key>'
```

## Endpoint delivery stats

`GET /webhook-endpoints/stats` rolls up the delivery log per endpoint so the console can show delivery
health without a stored health field. An optional `since` (RFC3339) bounds the window; absent, it
defaults to the last 30 days.

| Field          | Meaning                                                    |
| -------------- | ---------------------------------------------------------- |
| `endpointId`   | The endpoint the row aggregates.                           |
| `delivered`    | Total deliveries in the window (including in-flight).      |
| `succeeded`    | Deliveries that settled successfully.                      |
| `failed`       | Deliveries that terminally failed (`exhausted` or `dead`). |
| `lastDelivery` | The most recent delivery's timestamp.                      |

```sh theme={null}
curl "$DURATON_URL/webhook-endpoints/stats?since=2026-06-01T00:00:00Z"
```
