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: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-binaryover--data, noecho). 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
tyou sign must match thetin 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).
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 optionaldedupeKey: 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:
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 astatus - 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 (
ingestedordeduped) 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
dedupeKeyand 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
dedupeKeyis idempotent. Without adedupeKey, each replay starts a fresh run.
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.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.
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:
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 the169.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.