Webhook trigger
Fire a workflow on an inbound HTTP POST. The route lives below rflow's single
port (config.port) — no extra listener, no extra exposure.
secrets:
HOOK_KEY: ${BACKEND_HMAC_KEY}
workflows:
deposit-hook:
trigger:
webhook:
path: /hooks/deposit
auth: hmac
secret: "${{ secrets.HOOK_KEY }}"
steps:
- id: ack
notify:
channel: ops
message: "deposit webhook: ${{ trigger.args.user }} / ${{ trigger.args.amount }}"curl -X POST localhost:3940/hooks/deposit \
-H "content-type: application/json" \
-H "x-rflow-signature: $(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$KEY" -r | cut -d' ' -f1)" \
-d "$BODY"| Field | Required | Description |
|---|---|---|
path | ✅ | Route below rflow's port, e.g. /hooks/deposit. Must start with / and be unique across workflows (validated) |
auth | none | hmac | none — see below |
secret | with auth: hmac | The shared secret — usually ${{ secrets.<name> }} so it stays log-redacted |
idempotency_key | — | Dedupe identity for caller retries — an expression over the request (headers, body, path, query, method); see below |
idempotency_ttl | 7d | How long a key claims its run — min 1m, max 365d. A re-use window, not a retention guarantee |
on_duplicate | return_existing | Duplicate response: return_existing | accepted | conflict — see the table |
The trigger context
The JSON request body becomes trigger.args.*; the envelope is under
trigger.webhook.*:
| Path | Description |
|---|---|
trigger.args.<key> | The parsed JSON body (empty map for body-less POSTs) |
trigger.webhook.path | The route that received it |
trigger.webhook.received_at | Receipt timestamp (ISO) |
Authentication
auth: hmac verifies the x-rflow-signature header as the lowercase hex
HMAC-SHA256 of the raw body bytes under secret — exactly the signature an
http_call step sends, so two
rflow instances can call each other with zero glue. A bad or missing signature
is rejected before anything is claimed.
auth: none (the default) relies on network-level protection. rflow's port is
designed as a localhost / private-network operator tool — see the
observability warning before exposing it anywhere.
Delivery semantics — at-least-once, honestly
Without idempotency_key, every accepted POST claims a fresh trigger key
(webhook:{path}:{uuid}), so a caller that retries after a network blip
creates a second run. This is deliberate: webhooks have no inherent
dedupe identity (unlike a chain log or a cron tick), and inventing one from
the body would silently drop distinct calls. Callers own their retry dedupe;
the HTTP response body carries the run_id for correlation.
Paused workflows answer 429 instead of claiming — the caller's retry loop
naturally redelivers once the workflow is resumed. One exception when
idempotency_key is configured: a delivery whose key already holds a live
claim still answers per on_duplicate while paused (a read-only lookup —
no run is created). Without that, a pause longer than the key's remaining TTL
would keep the caller retrying past the duplicate answer, and the first retry
after unpause would re-open the expired key and run the workflow a second
time.
Idempotency — dedupe caller retries
Declare the request's business identity and rflow dedupes retries for you:
trigger:
webhook:
path: /hooks/deposit
auth: hmac
secret: "${{ secrets.HOOK_KEY }}"
idempotency_key: "${{ headers['x-idempotency-key'] }}"
idempotency_ttl: 7d # default; min 1m, max 365d
on_duplicate: return_existing # default; accepted | conflictThe key is an expression over the request — five roots are in scope
(and only these five; rflow validate rejects anything else):
| Root | Description |
|---|---|
headers | Header map, names lowercased — ${{ headers['x-idempotency-key'] }} |
body | The parsed JSON body — ${{ body.event_id }} |
query | Decoded query-string pairs — ${{ query.id }} |
method | The HTTP method (POST) |
path | The route that received the delivery |
Semantics:
- the evaluated key (hashed) becomes the claim identity
webhook:{workflow}:{sha256(key)}— a duplicate POST with the same key withinidempotency_ttlcreates no new run and answers peron_duplicate(see the table below) - a key that fails to evaluate (e.g. the header is missing) or evaluates to
an empty string is a
400and nothing runs — fix the request, don't retry it as-is - the TTL is a re-use window, not a retention guarantee: after expiry the same key may claim a new run (pick a TTL longer than your callers' longest retry horizon)
- a paused workflow still answers duplicates: a key with a live claim
answers per
on_duplicate(read-only, no run created); only keys with no live claim get the paused429— so pausing never stretches a caller's retry loop past the duplicate answer - the raw key + expiry are stored in
rflow.webhook_idempotency(queryable by operators); expired rows are pruned on boot and opportunistically - redaction: headers are visible to the key expression only — they are
never journaled in the run's trigger payload or logged (logs carry the key
hash). The evaluated key itself is stored in plaintext in
rflow.webhook_idempotency.raw_keyfor the length of the TTL, so do not derive keys from secret-bearing headers (authorization, signatures) — use a caller-suppliedx-idempotency-keyor a business identity from the body instead
Without idempotency_key nothing changes — today's uuid-per-delivery
behaviour is untouched.
rflow validate prints advice (never blocking) when a webhook-triggered
workflow sends transactions without an idempotency_key; rflow doctor
carries the same nuance next to its webhook auth check.
Responses
First delivery (unchanged):
| Status | Meaning |
|---|---|
202 | Run claimed — body carries the run_id |
200 | Accepted but no run claimed (run_id: null) |
400 | idempotency_key failed to evaluate or was empty — body carries error |
401 | HMAC verification failed |
429 | The workflow is paused — with idempotency_key, only when the key holds no live claim (live duplicates still answer per on_duplicate) |
404 | No workflow declares this path |
Duplicate delivery (same idempotency_key within the TTL), per on_duplicate:
| Mode | Status | Body |
|---|---|---|
return_existing (default) | 200 | {"status":"duplicate","run_id":"<original>","run_status":"<current>"} |
accepted | 202 | {"status":"duplicate"} — no ids leaked |
conflict | 409 | {"status":"duplicate","run_id":"<original>"} |