Concepts

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 now

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.

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

ResponseWhen
202Verified. The event is ingested.
400The body is not JSON, or could not be read.
401The signature does not verify. The body carries a reason naming which check failed (see Troubleshooting a 401).
404No source matches the token.
413The 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.

SchemeSignature headerHow 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.
stripeStripe-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.
githubX-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_webhookswebhook-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" }
reasonWhat failedWhere to look
missing_signatureThe request carried no signature header.Send the scheme's header (X-Duraton-Signature, or Stripe-Signature for the stripe scheme).
malformed_signatureThe 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_toleranceThe 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_mismatchThe 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-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:

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:

statusMeaning
ingestedVerified and emitted an event. See the delivery's eventId, and its runId if a run started.
dedupedVerified, but dropped by the source's dedupeKey, so no event was produced.
unauthorizedThe 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.
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.

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:

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 ingested

The 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 deliveries

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.

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 },
    });
  },
});

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:

HeaderValue
Content-Typeapplication/json
User-AgentDuraton-Webhooks/<n>
X-Duraton-IdThe delivery id.
X-Duraton-TimestampThe unix send time, matching the signature's t.
X-Duraton-EventThe lifecycle kind, e.g. run.failed.
X-Duraton-Signaturet=<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.

ResponseOutcome
2xxsucceeded.
5xx, 429, or a transport error (timeout, connection refused)Retried: failed between attempts, exhausted once the row's maxAttempts is spent.
Any other 4xxdead 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 attempt

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.

On this page