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.

POST/api/workflows/{id}/webhook

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

Response
{
  "enabled": true,
  "url": "http://localhost:8000/api/hooks/9bc62f209c52/f3a1c09be2d44d1808b7a5c6e2f09a41"
}
GET/api/workflows/{id}/webhook

Read the current state and hardening config. url is null while the webhook is disabled.

Response
{
  "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"
}
DELETE/api/workflows/{id}/webhook

Disable the webhook by clearing its secret. The old URL stops working immediately; re-enabling later issues a different secret. Returns 204.

The secret is 32 hexadecimal characters (16 random bytes). It is stored on the workflow and shown only as part of the assembled 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.

POST/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.

FieldTypeDescription
workflow_idpathThe target workflow's id.
secretpathThe webhook secret. Compared with hmac.compare_digest — constant-time, no early exit.
bodyjsonA JSON object becomes the run input directly. A non-object (array/string/number) is wrapped as { "payload": ... }. An empty body is treated as {}.
Trigger a run
$ 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 with 400
  • The response is 201 with { "run_id", "status": "pending" } — poll GET /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.

PUT/api/workflows/{id}/webhook/config

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

FieldTypeDescription
require_signatureboolWhen true, every delivery must carry a valid HMAC signature + timestamp header (see below) or it is rejected 401.
timestamp_tolerance_secondsintMax clock skew between the signed X-Ballast-Timestamp and now. Older requests are rejected as replays (401).
dedup_window_secondsint0 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_fieldstringThe 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; rejected 401 if outside timestamp_tolerance_seconds
  • X-Ballast-Signature sha256=<hex>, an HMAC-SHA256 over "{timestamp}." + raw_body keyed by the webhook secret, compared in constant time
Signing a delivery (Python)
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.

A duplicate delivery within the window
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 the 404 above 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 as webhook_run

With signing off, the URL is the credential — treat it as a secret

Without require_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:

FieldTypeDescription
1. auth404Workflow exists and the constant-time secret comparison passes. Otherwise 404 Not found.
2. signature401When require_signature is on: the HMAC signature is valid and the timestamp is within tolerance. Otherwise 401.
3. body400A present body parses as JSON. Otherwise 400 'Body must be JSON (or empty)'.
4. de-dup201When 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 + budget402The workspace is within its monthly run limit and any block budget has room. Over either returns 402 and no run is created.
6. graph400The workflow graph validates (including every custom:<name> reference). Errors return 400 'Workflow graph invalid: …'.
7. run201A pending run is created at the workflow's current version and execution starts. Returns { run_id, status }.

Response codes at a glance

FieldTypeDescription
201successRun created. Body: { run_id, status: "pending" }.
400clientBody present but not JSON, or the workflow graph is invalid.
401signatureSigning is required and the signature is missing, malformed, or the timestamp is outside tolerance (replay).
402quotaMonthly run limit reached, or a block budget is exhausted — raise the limit/budget or wait for the next period.
404authWrong workflow id, webhook not enabled, or wrong secret (identical response for all three).
429ratePer-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:

Condition node expression
# 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_run action
  • 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 201 means the run was created and started, not finished. Senders that retry on a slow response would otherwise create duplicate runs — set a dedup_window_seconds and give each event a stable id, and a retry returns the original run instead. (The authenticated POST /api/workflows/{id}/runs endpoint offers the same guarantee via an Idempotency-Key header.)
  • Rate limiting applies. When RATE_LIMIT_PER_MINUTE is set, the hooks path is subject to the per-IP ceiling like any other endpoint