Webhook trigger
Fire a workflow on an inbound HTTP POST. The route lives below rflow's single
port (config.port), so it adds no extra listener and no extra exposure.
rflow_version: 1
name: deposit-hook
config:
port: 3940
db_connection: ${DATABASE_URL}
secrets:
HOOK_KEY: ${BACKEND_HMAC_KEY}
notifications:
channels:
ops:
console: {}
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.
Use a strong, randomly generated shared secret. Empty or whitespace-only literal secrets fail validation, and a secret expression that resolves to an empty or whitespace-only value stops startup before webhook routes are served. Nonblank secrets are used exactly as supplied, including any surrounding whitespace; rflow does not trim the signing key.
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. Deliberate: webhooks have no inherent dedupe
identity, and inventing one from the body would silently drop distinct calls.
Callers own their retry dedupe; the response body carries the run_id for
correlation.
Paused workflows answer 429 instead of claiming; the caller's retry loop
redelivers once the workflow is resumed. One exception with idempotency_key:
a delivery whose key already holds a live claim still answers per
on_duplicate while paused (a read-only lookup, no run created). Otherwise a
pause longer than the key's remaining TTL would re-open the expired key on the
first retry after unpause and run the workflow a second time.
Idempotency — dedupe caller retries
Declare the request's business identity and rflow dedupes retries for you:
rflow_version: 1
name: deposit-hook
config:
port: 3940
db_connection: ${DATABASE_URL}
secrets:
HOOK_KEY: ${BACKEND_HMAC_KEY}
notifications:
channels:
ops:
console: {}
workflows:
deposit-hook:
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 | conflict #
steps:
- id: ack
notify:
channel: ops
message: "deposit webhook: ${{ trigger.args.user }} / ${{ trigger.args.amount }}"The 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. missing header) 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, so 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 - 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, never
journaled in the 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
Without idempotency_key nothing changes: 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 a key with no live claim gets this answer (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>"} |