Skip to main content
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 - 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:
The receive URL is not an input: Duraton generates a 128-bit random token for it (W3C capability-URL guidance 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.
A verified JSON body becomes the event payload and is ingested as payment.received, triggering whatever workflows subscribe to it. 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. 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:

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:
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:
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.

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: 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:

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.
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.
Inspect and replay a delivery over the SDK - see the webhooks API for the REST shape:
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.
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.
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.

Signing and verification

Every delivery POST carries this header set: 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:
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. Outbound retries back off exponentially: 1s before the second attempt, doubling each time, capped at 30s. This is a different subsystem from step 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:

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.