Connect
Webhook triggers
Every workflow can expose one inbound webhook URL. Anything that can POST JSON — Zendesk, Stripe, GitHub, a CI job, your own backend — starts a governed run, and the request body becomes the run's input. No SDK, no polling, no auth handshake: the secret in the URL is the credential.
A webhook is the inbound counterpart to the rest of the platform: where the authenticated POST /api/workflows/{id}/runs endpoint lets you start runs, a webhook lets an external system start them without holding a workspace API key. You enable it, hand the resulting URL to the sender, and every POST to that URL creates a run that flows through the same engine — graph validation, quota, checkpoints, gates, retries, and audit included.
Enable, rotate, and disable
A workflow's webhook is controlled by three endpoints under the workflow itself. Enabling mints a fresh secret; the returned url embeds it and is the only place the full URL is shown assembled for you.
/api/workflows/{id}/webhookEnable the webhook — or, if it's already enabled, rotate to a brand-new secret. Returns the full inbound URL. The previous URL stops working the instant the secret rotates.
{
"enabled": true,
"url": "http://localhost:8000/api/hooks/9bc62f209c52/f3a1c09be2d44d1808b7a5c6e2f09a41"
}
/api/workflows/{id}/webhookRead the current state and hardening config. url is null while the webhook is disabled.
{
"enabled": true,
"url": "http://localhost:8000/api/hooks/9bc62f209c52/f3a1c09be2d4…",
"require_signature": false,
"timestamp_tolerance_seconds": 300,
"dedup_window_seconds": 0,
"event_id_field": "event_id"
}
/api/workflows/{id}/webhookDisable the webhook by clearing its secret. The old URL stops working immediately; re-enabling later issues a different secret. Returns 204.
url— there is no separate "reveal secret" call, so capture the URL when you enable or rotate.The inbound trigger endpoint
The URL has the shape /api/hooks/{workflow_id}/{secret}. It is deliberately exempt from session and API-key auth so third-party systems can call it directly — the secret path segment is the entire credential.
/api/hooks/{workflow_id}/{secret}Start a run. The JSON request body becomes the run input. Returns 201 with the new run id and status. Auth-exempt — the secret is verified in constant time against the workflow's stored secret.
| Field | Type | Default | Description |
|---|---|---|---|
| workflow_id | path | — | The target workflow's id. |
| secret | path | — | The webhook secret. Compared with hmac.compare_digest — constant-time, no early exit. |
| body | json | {} | A JSON object becomes the run input directly. A non-object (array/string/number) is wrapped as { "payload": ... }. An empty body is treated as {}. |
$ curl -X POST "http://localhost:8000/api/hooks/9bc62f209c52/f3a1c09be2d44d1808b7a5c6e2f09a41" \
-H "content-type: application/json" \
-d '{"ticket": "I was double charged", "customer_id": "cus_412"}'
HTTP/1.1 201 Created
{ "run_id": "af772b3602ef", "status": "pending" }
How the body becomes input
- A JSON object body becomes the run input directly — templates and expressions read its keys as
{ticket},{customer_id}, and so on, just like a manual run's input - A non-object body (array, string, number) is wrapped as
{"payload": ...}so state always has a predictable shape - An empty body is accepted and treated as
{}; a body that is present but not valid JSON is rejected with400 - The response is
201with{ "run_id", "status": "pending" }— pollGET /api/runs/{run_id}or subscribe to its event stream to follow progress
Signing, replay protection & de-duplication
The URL secret is enough on its own, but for anything that touches money or state you can turn on three additional, opt-in controls per workflow — so a leaked URL is no longer sufficient, a captured request cannot be replayed, and a sender that retries never double-acts.
/api/workflows/{id}/webhook/configConfigure inbound-webhook hardening: HMAC signature requirement, timestamp replay tolerance, and the ingest de-duplication window + event-id field. Returns the same shape as GET /webhook.
| Field | Type | Default | Description |
|---|---|---|---|
| require_signature | bool | false | When true, every delivery must carry a valid HMAC signature + timestamp header (see below) or it is rejected 401. |
| timestamp_tolerance_seconds | int | 300 | Max clock skew between the signed X-Ballast-Timestamp and now. Older requests are rejected as replays (401). |
| dedup_window_seconds | int | 0 | 0 disables ingest de-dup. When > 0, a duplicate delivery of the same event id within the window returns the original run instead of starting a new one. |
| event_id_field | string | event_id | The body field used as the de-dup / replay id when the X-Ballast-Event-Id header is absent (falls back to a body id field). |
HMAC signatures & timestamps
With require_signature on, each delivery must send two headers. The signature is computed over "{timestamp}." + raw_body — the timestamp is inside the signed material, so a captured signature cannot be replayed with a fresh time:
X-Ballast-Timestamp— unix seconds; rejected401if outsidetimestamp_tolerance_secondsX-Ballast-Signature—sha256=<hex>, an HMAC-SHA256 over"{timestamp}." + raw_bodykeyed by the webhook secret, compared in constant time
import hmac, hashlib, time, json, requests
secret = "f3a1c09be2d4…" # the webhook secret from the URL
body = json.dumps({"event_id": "evt_912", "amount": 4200}).encode()
ts = str(int(time.time()))
sig = "sha256=" + hmac.new(secret.encode(), f"{ts}.".encode() + body,
hashlib.sha256).hexdigest()
requests.post(url, data=body, headers={
"content-type": "application/json",
"X-Ballast-Timestamp": ts,
"X-Ballast-Signature": sig,
})
Ingest de-duplication
Set dedup_window_seconds above zero and Ballast keys each delivery by its event id (the X-Ballast-Event-Id header, else the configured event_id_field, else a body id). A duplicate delivery of the same event within the window returns the original run instead of creating a new one, and concurrent duplicate deliveries collapse to a single run (a unique constraint on workflow + event id admits exactly one). This makes the unauthenticated endpoint safe for at-least-once senders — no exactly-once handshake required.
HTTP/1.1 201 Created
{ "run_id": "af772b3602ef", "status": "duplicate", "deduplicated": true }
Security model
Whether or not signing is on, the secret path segment is verified first, and verification is written to leak as little as possible:
- The submitted secret is compared with
hmac.compare_digest— a constant-time comparison, so response timing does not reveal how many leading characters were correct - A wrong workflow id, a workflow with no webhook enabled, and a wrong secret all return the identical
404 Not found— a caller cannot tell which of the two path segments was wrong, so valid workflow ids are not enumerable through this endpoint - With signing enabled, a missing, malformed, or expired signature is a
401— distinct from the404above so an authorized sender can tell a signing mistake from a wrong URL - Every enable, rotate, disable, and config change is written to the audit log (
webhook_enable,webhook_rotate,webhook_disable,webhook_config), and each triggered run is logged aswebhook_run
With signing off, the URL is the credential — treat it as a secret
Withoutrequire_signature, possession of the full URL is sufficient to trigger runs (subject to signature, quota, budget, and graph validation). Store it in the sending system's secret store, never in client-side code, a public repo, or a screenshot. If it leaks, rotate it (POST the webhook endpoint) or disable it (DELETE) — both revoke the old URL immediately. For anything sensitive, turn on HMAC signing so the URL alone is not enough.What a valid request goes through
A correct id and secret is only the first gate. Before a run is created, the request passes the same checks a manual trigger does:
| Field | Type | Default | Description |
|---|---|---|---|
| 1. auth | 404 | — | Workflow exists and the constant-time secret comparison passes. Otherwise 404 Not found. |
| 2. signature | 401 | — | When require_signature is on: the HMAC signature is valid and the timestamp is within tolerance. Otherwise 401. |
| 3. body | 400 | — | A present body parses as JSON. Otherwise 400 'Body must be JSON (or empty)'. |
| 4. de-dup | 201 | — | When a dedup window is set: a duplicate event id within the window short-circuits here and returns the original run ({ status: "duplicate", deduplicated: true }). |
| 5. quota + budget | 402 | — | The workspace is within its monthly run limit and any block budget has room. Over either returns 402 and no run is created. |
| 6. graph | 400 | — | The workflow graph validates (including every custom:<name> reference). Errors return 400 'Workflow graph invalid: …'. |
| 7. run | 201 | — | A pending run is created at the workflow's current version and execution starts. Returns { run_id, status }. |
Response codes at a glance
| Field | Type | Default | Description |
|---|---|---|---|
| 201 | success | — | Run created. Body: { run_id, status: "pending" }. |
| 400 | client | — | Body present but not JSON, or the workflow graph is invalid. |
| 401 | signature | — | Signing is required and the signature is missing, malformed, or the timestamp is outside tolerance (replay). |
| 402 | quota | — | Monthly run limit reached, or a block budget is exhausted — raise the limit/budget or wait for the next period. |
| 404 | auth | — | Wrong workflow id, webhook not enabled, or wrong secret (identical response for all three). |
| 429 | rate | — | Per-IP rate limit exceeded — only when RATE_LIMIT_PER_MINUTE is set. |
Example: Stripe-style event routing
Point a payment provider's event destination at the webhook URL and let the workflow branch on the delivered payload with a condition node:
# event payload posted by the provider:
# {"type": "charge.dispute.created", "amount": 4200, "currency": "usd"}
type == 'charge.dispute.created' and amount > 1000
High-value disputes route to a human gate for review; the rest flow straight to automated handling — the standard human-in-the-loop pattern, now triggered by an external system instead of a click in the console.
Semantics worth knowing
- One webhook per workflow. Need fan-out from a single event source to several workflows? Point it at one workflow whose graph fans out into parallel branches
- No member attribution. The trigger is unauthenticated, so webhook-started runs are not attributed to any workspace member; they are identifiable in the audit trail by the
webhook_runaction - Runs pin the current version. A run captures the workflow version at trigger time, so a graph edit mid-flight never changes an in-progress run
- Delivery is synchronous but execution is not. A
201means the run was created and started, not finished. Senders that retry on a slow response would otherwise create duplicate runs — set adedup_window_secondsand give each event a stable id, and a retry returns the original run instead. (The authenticatedPOST /api/workflows/{id}/runsendpoint offers the same guarantee via anIdempotency-Keyheader.) - Rate limiting applies. When
RATE_LIMIT_PER_MINUTEis set, the hooks path is subject to the per-IP ceiling like any other endpoint