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 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:
the response carries up to limit deliveries plus an X-Next-Cursor header when more exist; the header is
absent on the last page.
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 pagefrom duraton.client import AsyncDuratonClient, ListWebhookDeliveriesOptions
async with AsyncDuratonClient() as dx:
page = await dx.webhooks.deliveries.list(ListWebhookDeliveriesOptions(app="shop", status="exhausted", limit=20))
page.deliveries, page.next_cursorcurl "$DURATON_URL/webhook-deliveries?app=shop&status=exhausted&limit=20"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.
{
"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. |
const detail = await duraton.webhooks.deliveries.get("<id>");
detail.attempts; // WebhookDeliveryAttempt[]from duraton.client import AsyncDuratonClient
async with AsyncDuratonClient() as dx:
detail = await dx.webhooks.deliveries.get("<id>")
detail.attempts # WebhookDeliveryAttempt[]curl "$DURATON_URL/webhook-deliveries/<id>"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.
curl -X POST "$DURATON_URL/webhook-deliveries/<id>/redeliver"The inbound delivery log
Every POST to a source's receive URL 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.
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 pagecurl "$DURATON_URL/webhook-source-deliveries?source=<id>&status=unauthorized&limit=20"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[]):
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.
{
"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.
const detail = await duraton.webhooks.sourceDeliveries.get("<id>");
for (const a of detail.attempts) console.log(a.trigger, a.status, a.actor);curl "$DURATON_URL/webhook-source-deliveries/<id>"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 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. |
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 ingestedcurl -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" }The inbound source-delivery routes are exposed by the TypeScript SDK's
duraton.webhooks.sourceDeliveries surface (list, listAll, get, replay). Python and Go SDK
support is on the roadmap; until then, use the REST routes directly.
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.
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.
# 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: 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.
# 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. |
curl "$DURATON_URL/webhook-endpoints/stats?since=2026-06-01T00:00:00Z"Evals API
Measure agent quality from your own tooling: score runs, author datasets of test cases, fan them through a workflow, and fork a finished run with one change.
Runners & Apps API
See which apps and runners are live right now - they appear because a runner registered itself, with its self-reported metadata attached.