Webhooks
Receive signed external POSTs as events, and deliver signed outbound webhooks on run lifecycle or from workflow code, over one durable delivery log.
Webhooks are two halves of one feature. Inbound receivers turn a signature-verified external POST
into a Durablex event, fed through the normal ingest path. Outbound delivery sends a signed POST to a
URL - either because an endpoint subscribed to a run lifecycle transition, or because a workflow called
ctx.webhook.send. Every outbound send lands in a durable delivery + attempt log you can read back.
Both directions sign with the same HMAC scheme (X-Durablex-Signature). Receivers and endpoints are
config-as-data you can manage two equivalent ways - both writing the same rows: the console's
Webhooks view or the webhooks API. A failed delivery can be redelivered from
either surface.
Enabling webhooks
Webhooks are available in every workspace. Durablex Cloud seals every receiver and endpoint secret at rest for you, so there's nothing to turn on. Outbound delivery is a server-side request, so loopback and private addresses are blocked by default (see Egress safety).
Inbound receivers
A receiver maps a public slug onto an event. Create one in the console's Webhooks view (or the webhooks API) with an event, an app, a public slug, and a signing secret (generated for you, shown once):
- event -
payment.received - app -
shop - slug -
stripe-payments
The provider then posts to POST /webhooks/stripe-payments. That path is public - an external
caller carries no Durablex API key - so the receiver row is the authority: the engine looks the receiver
up by its (high-entropy) slug, verifies the signature with the receiver's own secret, and takes the namespace
and event mapping from the row. A verified, JSON body becomes the event payload and is ingested as
payment.received, which triggers whatever workflows subscribe to it. A bad signature is 401, an
unknown slug is 404, and a non-JSON body is 400 - none of them start a run.
Outbound subscriptions
An endpoint subscribes a URL to one or more run lifecycle kinds. When a matching transition happens, the engine enqueues one delivery per subscribed endpoint. Create one in the console's Webhooks view (or the webhooks API) with a URL, the lifecycle kinds to subscribe to, an app, and a secret:
- url -
https://hooks.example.com/durablex - on -
run.failed,run.succeeded - app -
shop
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. An empty --app subscribes to every app in the namespace; a set one narrows to that app.
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.
import { defineWorkflow } from "@durablex/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, and the delivery row has no per-send secret, so a custom
send is delivered unsigned. To get a signed delivery, subscribe a registered endpoint (which has a
secret) instead. The first argument is an explicit, stable step id so the send replays deterministically.
Delivery, retries, and the attempt log
Both triggers write the same webhook_delivery row and ride one delivery sweeper. The sweeper signs (for
endpoint deliveries), POSTs, records the attempt, and classifies the response:
- 2xx ->
succeeded. - 5xx, 429, or a transport error (timeout, connection refused) -> retried with exponential
backoff (
failedbetween attempts), thenexhaustedoncemaxAttemptsis spent. - any other 4xx ->
deadimmediately (a non-retryable client error, e.g. a bad URL).
Every POST appends a row to the attempt log - status code, response snippet, error, and duration - so a failing delivery is debuggable from its history, not just its final state. Read the log back via the webhooks API or the console's Webhooks view.
Signing and verification
Signed deliveries carry an X-Durablex-Signature: t=<unix>&s=<hmacHex> header, where the HMAC-SHA256 is
computed over <unix>.<rawBody> with the shared secret. To verify on the receiving side, recompute the
HMAC over the same string and compare in constant time; reject a timestamp outside a few minutes of now.
This is the same scheme a Durablex inbound receiver verifies, so a Durablex engine can deliver to another
Durablex receiver end to end.
Egress safety
Outbound delivery is a server-side request to a URL you provide, so Durablex Cloud guards against SSRF:
loopback, private (RFC-1918), link-local (including the 169.254.169.254 cloud-metadata IP), and
unspecified addresses are blocked, checked at dial time against the resolved IP so DNS rebinding
cannot slip past. Point endpoints at public URLs your receiver can reach.