Webhooks
Let a third party start a run, and let a finished run tell the outside world - verified inbound POSTs, signed outbound ones, one durable delivery log.
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:
const source = await duraton.webhooks.sources.create({
name: "Stripe",
app: "shop",
eventName: "payment.received",
});
console.log(source.receiveUrl); // https://webhooks.duraton.dev/9f3c8a2b... - give this to your provider
console.log(source.secret); // shown once - store it nowfrom duraton.client import SourceInput
source = await duraton.webhooks.sources.create(SourceInput(
name="Stripe",
app="shop",
event_name="payment.received",
))
print(source.receive_url) # https://webhooks.duraton.dev/9f3c8a2b... - give this to your provider
print(source.secret) # shown once - store it nowThe 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.
# what your provider does, signed with the source's secret
curl -X POST "https://webhooks.duraton.dev/9f3c8a2b..." \
-H 'content-type: application/json' \
-H 'x-duraton-signature: t=1752400000&s=<hmacHex>' \
-d '{ "amount": 4200, "currency": "usd" }'A verified JSON body becomes the event payload and is ingested as payment.received, triggering whatever
workflows subscribe to it.
| Response | When |
|---|---|
202 | Verified. The event is ingested. |
400 | The body is not JSON, or could not be read. |
401 | The signature does not verify. The body carries a reason naming which check failed (see Troubleshooting a 401). |
404 | No source matches the token. |
413 | The body is over the size limit. |
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.
| Scheme | Signature header | How the body is signed |
|---|---|---|
hmac_sha256 (default) | X-Duraton-Signature: t=<unix>&s=<hmacHex> | HMAC-SHA256 over `${t}.${rawBody}`, hex-encoded, with the source's secret. |
stripe | Stripe-Signature: t=<unix>,v1=<hmacHex> | HMAC-SHA256 over `${t}.${rawBody}`, hex-encoded, with the source's secret; several v1= values are accepted so a rolled secret keeps verifying. |
github | X-Hub-Signature-256: sha256=<hmacHex> | HMAC-SHA256 over the raw body, hex-encoded, with the source's secret. No timestamp in the scheme, so there is no skew check. |
standard_webhooks | webhook-id, webhook-timestamp, webhook-signature: v1,<hmacBase64> | HMAC-SHA256 over `${id}.${timestamp}.${rawBody}`, base64-encoded, with the source's secret (conventionally a whsec_-prefixed base64 key); several space-separated signatures are accepted so a rolled secret keeps verifying. |
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:
const source = await duraton.webhooks.sources.create({
name: "Stripe",
app: "shop",
eventName: "payment.received",
scheme: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET, // Stripe's signing secret, not a generated one
});Selecting a non-default scheme on create is available in the TypeScript SDK and the webhooks API
today. Python and Go SDK support is on the roadmap; until then, create a provider-scheme source over
the API.
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:
{ "error": "signature verification failed", "reason": "timestamp_out_of_tolerance" }reason | What failed | Where to look |
|---|---|---|
missing_signature | The request carried no signature header. | Send the scheme's header (X-Duraton-Signature, or Stripe-Signature for the stripe scheme). |
malformed_signature | The signature header was present but could not be parsed. | Match the header format exactly - t=<unix>&s=<hex> for the default scheme, t=<unix>,v1=<hex> for stripe. |
timestamp_out_of_tolerance | The signed timestamp is more than 5 minutes from Duraton's clock. | Sign with a current timestamp and keep the sender's clock in sync. |
signature_mismatch | The header parsed, but the signature did not match the body. | Wrong secret, or the body was altered in transit - work through the checks below. |
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).
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:
const source = await duraton.webhooks.sources.create({
name: "Stripe",
app: "shop",
eventName: "payment.received",
scheme: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET,
dedupeKey: "id", // Stripe's event id: `evt_...`
});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:
status | Meaning |
|---|---|
ingested | Verified and emitted an event. See the delivery's eventId, and its runId if a run started. |
deduped | Verified, but dropped by the source's dedupeKey, so no event was produced. |
unauthorized | The signature did not verify. A failureReason names the failed check - the same values as the 401 reasons: missing_signature, malformed_signature, timestamp_out_of_tolerance, 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. |
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.
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:
const { deliveries } = await duraton.webhooks.sourceDeliveries.list({ status: "unauthorized" });
const detail = await duraton.webhooks.sourceDeliveries.get(deliveries[0].id);
for (const a of detail.attempts) console.log(a.trigger, a.status, a.actor);
// re-ingest a previously verified delivery's stored body; the result reports what it produced
const result = await duraton.webhooks.sourceDeliveries.replay(detail.id);
console.log(result.status, result.eventId, result.runId); // "ingested" | "deduped"; eventId/runId set only when it ingestedThe inbound sourceDeliveries surface is available in the TypeScript SDK and the webhooks API today.
Python and Go SDK support is on the roadmap; until then, read and replay inbound deliveries over the API.
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.
const endpoint = await duraton.webhooks.endpoints.create({
name: "Acme prod",
app: "shop", // omit to subscribe to every app in the project
url: "https://hooks.example.com/duraton",
eventKinds: ["run.failed", "run.succeeded"],
});
console.log(endpoint.secret); // shown once - this is what signs the deliveriesfrom duraton.client import EndpointInput
endpoint = await duraton.webhooks.endpoints.create(EndpointInput(
name="Acme prod",
app="shop", # omit to subscribe to every app in the project
url="https://hooks.example.com/duraton",
event_kinds=["run.failed", "run.succeeded"],
))
print(endpoint.secret) # shown once - this is what signs the deliveriesThe 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.
import { defineWorkflow } from "@duraton/sdk";
export const orderShip = defineWorkflow({
name: "order.ship",
handler: async (ctx) => {
await ctx.webhook.send("notify-partner", {
url: "https://partner.example.com/shipments",
data: { orderId: ctx.event.data.orderId, shipped: true },
});
},
});from duraton import define_workflow
from duraton.context import StepContext
async def order_ship(ctx: StepContext) -> object:
await ctx.webhook.send(
"notify-partner",
url="https://partner.example.com/shipments",
data={"orderId": ctx.event.data["orderId"], "shipped": True},
)
order_ship_wf = define_workflow("order.ship", order_ship)var orderShip = duraton.DefineWorkflow(duraton.WorkflowDefinition{
Name: "order.ship",
Handler: func(c *duraton.Context) (any, error) {
order, err := duraton.EventData[Order](c)
if err != nil {
return nil, err
}
return nil, duraton.Webhook(c, "notify-partner", duraton.WebhookOptions{
URL: "https://partner.example.com/shipments",
Data: map[string]any{"orderId": order.ID, "shipped": true},
})
},
})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:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Duraton-Webhooks/<n> |
X-Duraton-Id | The delivery id. |
X-Duraton-Timestamp | The unix send time, matching the signature's t. |
X-Duraton-Event | The lifecycle kind, e.g. run.failed. |
X-Duraton-Signature | t=<unix>&s=<hmacHex> - endpoint deliveries only. |
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:
import { createHmac, timingSafeEqual } from "node:crypto";
const MAX_SKEW_SECONDS = 300;
export function verify(rawBody: string, header: string, secret: string): boolean {
const params = new URLSearchParams(header); // t=<unix>&s=<hmacHex>
const t = Number(params.get("t"));
const sig = params.get("s") ?? "";
if (Math.abs(Math.floor(Date.now() / 1000) - t) > MAX_SKEW_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}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.
| Response | Outcome |
|---|---|
2xx | succeeded. |
5xx, 429, or a transport error (timeout, connection refused) | Retried: failed between attempts, exhausted once the row's maxAttempts is spent. |
Any other 4xx | dead immediately - a non-retryable client error, e.g. a bad URL. |
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:
const { deliveries } = await duraton.webhooks.deliveries.list({ status: "exhausted" });
const detail = await duraton.webhooks.deliveries.get(deliveries[0].id);
for (const a of detail.attempts) console.log(a.statusCode, a.durationMs, a.error);
await duraton.webhooks.deliveries.redeliver(detail.id); // re-queue it for another attemptfrom duraton.client import ListWebhookDeliveriesOptions
page = await duraton.webhooks.deliveries.list(ListWebhookDeliveriesOptions(status="exhausted"))
detail = await duraton.webhooks.deliveries.get(page.deliveries[0].id)
for a in detail.attempts:
print(a.status_code, a.duration_ms, a.error)
await duraton.webhooks.deliveries.redeliver(detail.delivery.id) # re-queue it for another attemptEgress 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.
Event log
Trace any run back to exactly what started it - every ingested event is kept with what it triggered, queryable and streamable live.
Runners (connect vs serve)
Run your workflow code wherever it already lives: dial out over a WebSocket with connect, or expose an inbound HTTP endpoint with serve.