Webhook Module
Webhooks both ways: public endpoints that start a workflow when another system calls them, a Send webhook Task that notifies other systems with signed, retried deliveries, and a Wait for webhook step that pauses a flow until an external system calls back.
The Webhook Module adds one Start event on its own Channel, the Webhook start (Webhook), and two Tasks that work on any Channel. Typical uses: run a flow for every GitHub push or Stripe payment; let a partner's system start a process by posting JSON to a URL; tell a customer's system about an order with a signed webhook; pause a flow until a payment provider or a long job reports back.
Configuration
The Webhook Module is configured per Environment: each Environment has its own endpoints (its own URLs and secrets) and its own outbound signing secret, so Development and Production never share them.
| Field | Type | Required | Description |
|---|---|---|---|
| Endpoints | list | No | The inbound endpoints (below). At most 20 per Environment. |
| Outbound signing secret | password | No | Signs every webhook the Send webhook Task sends (Standard Webhooks headers) unless the Task brings its own secret. Use a whsec_ base64 secret for receivers that verify with a Standard Webhooks library; any other text works too. |
| Allowed internal hosts | text (advanced) | No | One host name or IP per line that Send webhook may call although it is a private or local address. Every other private address is refused. |
Endpoints
Each endpoint gets its own URL: {engine}/api/WebhookModule/{environmentToken}/{key}. The Environment token is the Environment's short URL id (the same one the Form and chat endpoints use). Anything after the key (…/{key}/orders/created) reaches the flow as {webhook.path}.
| Field | Type | Default | Description |
|---|---|---|---|
| Endpoint id | string | Stable name, exposed as {webhook.endpointId}. Lowercase letters, digits and _. | |
| Enabled | boolean | on | A disabled endpoint answers 404. |
| URL key | string | Last URL segment, 22 to 64 letters, digits, - or _. Treat it as a secret when the endpoint has no other authentication. Changing it retires the old URL within about 30 s. | |
| Authentication | choice | Shared secret header | none, secret, basic, hmac or standard (below). |
| Secret / Previous secret | password | The credential of the chosen mode. The previous secret is also accepted during a rotation. | |
| Secret header | string | X-Webhook-Secret | secret mode: the header the sender puts the secret in. Authorization: Bearer <secret> is always accepted too. |
| Username | string | basic mode: the user name to require. Empty: any user name with the right password. | |
| Signature header, prefix, encoding, field, signed content, timestamp header / field, tolerance | hmac mode: how the provider signs (recipes below). | ||
| Timestamp tolerance (s) | number | 300 | hmac with a timestamp and standard modes: a signed timestamp further than this from now is refused. 0: not checked. |
| Allowed methods | string | POST | Comma-separated: POST, PUT, PATCH, DELETE, GET. Others get a 405. |
| Event header / Event field | string | Where the event name comes from: a header (X-GitHub-Event), else a dotted path in the JSON body (type). Exposed as {webhook.event}. | |
| Delivery id header | string | The sender's unique delivery id (X-GitHub-Delivery, webhook-id). A delivery seen in the last 48 hours runs once. Empty: every request runs. | |
| Allowed networks | text | One IP or CIDR range per line. Other callers get a 404. Empty: any address. | |
| Requests per minute | number | 600 | Beyond it the sender gets a 429 and retries later. |
| Response status, content type, body | 200, application/json | The answer to an accepted delivery. {deliveryId} in the body is replaced. 204 sends no body. The flow runs afterwards and never answers the sender itself. |
Authentication modes
| Mode | What the sender does | Typical use |
|---|---|---|
none | Nothing: the URL key is the only secret. | Internal tools, a quick test. Restrict Allowed networks when you can. |
secret | Puts the secret in the Secret header or as Authorization: Bearer. | Your own apps, Zapier/Make-style senders. |
basic | HTTP Basic with the user name and the secret as password. | Providers that offer Basic auth (Aculab, some SaaS). |
hmac | Signs the raw body with HMAC-SHA256 and sends it in a header. | GitHub, Shopify, Stripe, most providers. |
standard | Standard Webhooks headers webhook-id, webhook-timestamp, webhook-signature. | Svix-powered providers, another EW-Flow Environment's Send webhook. |
HMAC recipes
| Provider | Signature header | Prefix | Encoding | Field | Signed content | Timestamp field |
|---|---|---|---|---|---|---|
| GitHub | X-Hub-Signature-256 | sha256= | hex | body | ||
| Shopify | X-Shopify-Hmac-Sha256 | base64 | body | |||
| Stripe | Stripe-Signature | hex | v1 | timestamp.body | t | |
Generic HMAC(body) in hex | the provider's header | hex | body |
Set the Secret to the provider's signing secret. GitHub: also set Event header X-GitHub-Event and Delivery id header X-GitHub-Delivery. Stripe: Event field type, Delivery id header empty (Stripe's event id is in the body: use {webhook.body.id} in the flow).
A mode that needs a secret with none configured refuses every delivery (503, logged), never lets them through.
Webhook start
Starts the Process when a request reaches one of the Environment's endpoints and passes its checks. A Process may have other starts next to it (one per Channel); the Webhook Channel has nobody to answer, so an Input Task fails there.
| Field | Type | Default | Description |
|---|---|---|---|
| Only these endpoints | string | (all) | Comma-separated endpoint ids. |
| Only these events | string | (all) | Comma-separated event names; a trailing * matches a prefix (order.*). With a filter, a delivery without an event never matches. |
| Webhook variable | output | {webhook} | Receives the delivery (below). |
A delivery the filters drop is accepted (the configured answer) and not run, so the provider does not retry it.
Testing. In the editor's chat, start the Process at the Webhook start with test data, e.g. { "event": "push", "body": { "ref": "refs/heads/main" }, "headers": { "X-GitHub-Delivery": "t-1" }, "endpointId": "github" }. body may be JSON text or an object; method, query, path, text and ip are accepted too. {webhook.isTest} is true.
The {webhook} variable
| Path | Example | Description |
|---|---|---|
{webhook.endpointId} | github | The endpoint that received it. |
{webhook.deliveryId} | 72d3162e-… | The sender's delivery id when the endpoint names its header, else an id of this delivery. |
{webhook.event} | push | From the event header or field; empty when neither is set. |
{webhook.method}, {webhook.path}, {webhook.url} | POST, orders/created | The request. |
{webhook.contentType} | application/json | |
{webhook.ip} | 192.30.252.10 | The caller's address (also {webhook.from}). |
{webhook.headers.<name>} | {webhook.headers.x_github_event} | Headers by lowercase name with - as _. Authorization, cookies and the secret header read [redacted]. |
{webhook.query.<name>} | {webhook.query.ref} | Query string values. |
{webhook.body…} | {webhook.body.order.id}, {webhook.body.items.0.sku} | The parsed JSON body (objects, lists, numbers, booleans), or a form body by field name. Empty for other content types. |
{webhook.text} | The raw body (up to 256 KB). | |
{webhook.receivedAt}, {webhook.channel}, {webhook.isTest}, {webhook.to} | When it arrived, the run's Channel, test mode, the endpoint id. |
Bodies over 1 MB are refused (413). A delivery runs on its own session; the sender's answer never waits for the flow.
Send webhook
Sends a body to a URL the way webhook providers do, and stores the outcome. Prefer the Standard Module's HTTP request for calls whose answer the flow needs; use Send webhook to notify.
| Field | Type | Default | Description |
|---|---|---|---|
| URL | template | Absolute http(s) URL. Redirects are not followed; private and local addresses are refused unless listed in Allowed internal hosts. | |
| Method | choice | POST | POST, PUT or PATCH. |
| Event | template | Sent as the webhook-event header. | |
| Headers | list | Extra headers (an API key). Content-Type, Content-Length and the webhook-* headers cannot be listed. | |
| Body | code | Sent as typed with {variables} filled in as they are (build JSON values in a script when they may hold quotes). | |
| Content type | string | application/json | |
| Sign the webhook | boolean | on | Adds webhook-signature; off sends only webhook-id and webhook-timestamp. |
| Signing secret | template | Overrides the Environment's outbound secret for this step (a per-customer secret from a record). | |
| Attempts | number | 3 | The first attempt plus retries on no connection, timeout, 408, 425, 429 and 5xx answers (1 to 5). Other answers are final. |
| Retry delay | seconds (advanced) | 2 | Doubles on each further attempt, up to 30 s. |
| Timeout | seconds (advanced) | 30 | Per attempt, at most 120. |
| Max response | KB (advanced) | 256 | Longer answers are cut in {delivery.body}. |
| Treat non-2xx as error | boolean | on | On: an answer other than 2xx after the last attempt takes the 'error' flow (a warning, the answer still stored). Off: branch on {delivery.status}. No answer at all is an error either way. |
| Store result in | output | {delivery} | The outcome (below). |
Headers sent (Standard Webhooks): webhook-id (the same on every attempt, so receivers can deduplicate), webhook-timestamp (Unix seconds), webhook-signature = v1,<base64 HMAC-SHA256(secret, "{id}.{timestamp}.{body}")>, and webhook-event when set. A receiver on another EW-Flow Environment verifies them with an endpoint in standard mode and the same secret.
The {delivery} variable
| Path | Example | Description |
|---|---|---|
{delivery.ok} | true | The receiver answered 2xx. |
{delivery.status} | 200 | The last HTTP status; 0 when no answer came back. |
{delivery.attempts} | 1 | Attempts made. |
{delivery.deliveryId} | 9c1f… | The webhook-id sent. |
{delivery.body}, {delivery.headers.<name>} | The receiver's answer. | |
{delivery.durationMs} | 412 | Whole delivery, retries included. |
{delivery.error} | timed out after 30s | Why it failed; empty otherwise. |
Wait for webhook
Pauses the flow until an external system calls a one-time URL made for this step, then continues with what it sent. The URL is unique to the run and the step (an unforgeable key bound to the execution plus a one-time nonce), accepts any method, answers 202 at once and works once; a second call gets 410.
| Field | Type | Default | Description |
|---|---|---|---|
| Timeout | seconds | 0 | How long to wait before giving up on the 'error' flow with {callback.timedOut} = true. 0: as long as the session may last. At most 14 days. |
| Notify URL | template | Before waiting, post the callback URL there so the other system learns it (signed with the Environment's outbound secret when set, event callback.registered). Empty: nothing is sent. | |
| Notify body | code | {"callbackUrl": "…"} | {callbackUrl} is replaced by this step's URL; other {variables} are filled in. |
| Notify headers | list | Extra headers for the notification. | |
| Callback URL variable | output | {callbackUrl} | Receives the URL when the step starts. |
| Callback variable | output | {callback} | Receives what the callback sent (below). |
The URL exists only once the step runs, so the usual way to hand it out is Notify URL. A failed notification (after the retries) stops the step with an error instead of waiting for nobody. The engine needs a public address (Engine:PublicBaseUrl) to build the URL.
The {callback} variable
| Path | Description |
|---|---|
{callback.received} | True when a request reached the URL. |
{callback.timedOut} | True when the timeout ended the wait. |
{callback.method}, {callback.contentType}, {callback.ip}, {callback.receivedAt} | The request. |
{callback.headers.<name>}, {callback.query.<name>} | As for {webhook}. |
{callback.body…}, {callback.text} | The parsed JSON or form body, and the raw text. |
Limits and safety
- Inbound bodies: 1 MB; headers: 64, 4 KB each; the raw text kept in the variable: 256 KB.
- Every endpoint is rate-limited (its own per-minute limit, and 600 requests per minute per caller network across all endpoints). Limits and delivery-id deduplication need Redis; without it every request runs.
- A secret is compared in constant time; signed timestamps are checked against the tolerance; secrets never reach
{webhook.headers}. - Outbound: http and https only, no redirects, private and local addresses refused (loopback, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10, IPv6 local), checked again after DNS resolution and on every attempt.
- Callback URLs carry no secret of the engine: the key is an HMAC over the execution id with a key derived from
WebhookModule:CallbackKey(falling back toInternalApi:Token); set one of them so every engine instance verifies the same keys.