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 + pathPurpose
GET /webhook-deliveriesA 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}/redeliverRe-queue a delivery for an immediate fresh attempt. Returns 204.
GET /webhook-source-deliveriesA 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}/replayRe-ingest a verified delivery's stored body. Returns 200 with the replay outcome.
GET /webhook-endpointsThe outbound subscription configs (no secrets).
GET /webhook-endpoints/statsPer-endpoint delivery health over a window.
GET /webhook-endpoints/{id}One outbound endpoint config (no secret).
POST /webhook-endpointsCreate 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-sourcesThe inbound source configs (no secrets).
GET /webhook-sources/{id}One inbound source config (no secret).
POST /webhook-sourcesCreate 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:

ParamMeaningDefault
statusOne delivery status: pending, delivering, succeeded, failed, exhausted, dead.all
appRestrict to deliveries for one app's runs.all apps
limitPage size, 1-200.30
cursorOpaque 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 page

Each delivery has:

FieldMeaning
idDelivery id.
appThe app whose run produced the delivery.
endpointIdThe subscribed endpoint, or absent for a ctx.webhook.send.
urlThe destination Duraton POSTs to.
eventKindrun.succeeded, run.failed, run.cancelled, step.succeeded, step.failed, step.skipped, or custom.
sourceRunIdThe run whose lifecycle produced it (absent for a custom send).
payloadThe body Duraton sends.
statuspending, delivering, succeeded, failed (awaiting retry), exhausted (retries spent), or dead (non-retryable response).
attemptCount / maxAttemptsAttempts made / allowed.
lastStatusCodeThe latest attempt's HTTP status (absent until a code is recorded).
nextAttemptAtWhen Duraton next retries (while failed).
createdAt / updatedAtRFC3339 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 fieldMeaning
attempt1-based attempt number.
outcomesucceeded, http_error, timeout, connection_error, or skipped.
statusCodeThe HTTP status, when the partner responded.
responseSnippetA bounded prefix of the response body, for debugging.
errorThe transport error, when there was no response.
durationMsHow long the attempt took.
requestHeadersThe exact signed header set sent (identifiers + signature).
responseHeadersThe headers the endpoint returned, absent when there was no response.
const detail = await duraton.webhooks.deliveries.get("<id>");
detail.attempts; // WebhookDeliveryAttempt[]

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:

ParamMeaningDefault
sourceRestrict to one source's deliveries.all sources
statusOne admission outcome: ingested, deduped, unauthorized, invalid, too_large, misconfigured.all
limitPage size, 1-200.30
cursorOpaque 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 page

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[]):

statusMeaning
ingestedVerified and emitted an event (eventId). A run may have started - see runId.
dedupedVerified, but dropped by the source's dedupeKey, so no event was produced.
unauthorizedThe signature did not verify. failureReason names the failed check: missing_signature, malformed_signature, timestamp_out_of_tolerance, or signature_mismatch.
invalidVerified, but the body was not JSON.
too_largeThe body was over the size limit.
misconfiguredThe 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);

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 fieldMeaning
deliveryIdThe replayed delivery's id.
statusThe re-ingest outcome: ingested (a new event was emitted) or deduped (the source's dedupeKey dropped it).
eventIdThe event the replay emitted - present only when status is ingested.
runIdThe 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 ingested

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.

KindFires when
run.succeededA run reached its terminal succeeded state.
run.failedA run reached its terminal failed state.
run.cancelledA run was cancelled.
step.succeededA step settled successfully - per-step progress, not just per-step failure.
step.failedA step settled with a terminal failure.
step.skippedA 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 bodyMeaning
nameOptional label; the console falls back to the URL when absent.
appOptional - restrict deliveries to one app; absent means all apps.
urlRequired destination.
eventKindsOne or more of the subscribable event kinds: run.succeeded, run.failed, run.cancelled, step.succeeded, step.failed, step.skipped.
secretOptional - 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 bodyMeaning
nameOptional label; the console shows a short form of the receive URL when absent. Unique within the project.
appOptional - the app the produced event belongs to.
eventNameRequired - the Duraton event a verified post is mapped to.
targetAppOptional - target a specific app for the produced event.
dedupeKeyOptional - 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.
secretOptional - 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.

FieldMeaning
endpointIdThe endpoint the row aggregates.
deliveredTotal deliveries in the window (including in-flight).
succeededDeliveries that settled successfully.
failedDeliveries that terminally failed (exhausted or dead).
lastDeliveryThe most recent delivery's timestamp.
curl "$DURATON_URL/webhook-endpoints/stats?since=2026-06-01T00:00:00Z"

On this page