Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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"
FieldRequiredDescription
path✅Route below rflow's port, e.g. /hooks/deposit. Must start with / and be unique across workflows (validated)
authnonehmac | none (see below)
secretwith auth: hmacThe 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_ttl7dHow long a key claims its run (min 1m, max 365d). A re-use window, not a retention guarantee
on_duplicatereturn_existingDuplicate response: return_existing | accepted | conflict (see the table)

The trigger context

The JSON request body becomes trigger.args.*; the envelope is under trigger.webhook.*:

PathDescription
trigger.args.<key>The parsed JSON body (empty map for body-less POSTs)
trigger.webhook.pathThe route that received it
trigger.webhook.received_atReceipt 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):

RootDescription
headersHeader map, names lowercased: ${{ headers['x-idempotency-key'] }}
bodyThe parsed JSON body: ${{ body.event_id }}
queryDecoded query-string pairs: ${{ query.id }}
methodThe HTTP method (POST)
pathThe 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 within idempotency_ttl creates no new run and answers per on_duplicate (see the table below)
  • a key that fails to evaluate (e.g. missing header) or evaluates to an empty string is a 400 and 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 paused 429
  • 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_key for the length of the TTL, so do not derive keys from secret-bearing headers (authorization, signatures); use a caller-supplied x-idempotency-key or 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):

StatusMeaning
202Run claimed: body carries the run_id
200Accepted but no run claimed (run_id: null)
400idempotency_key failed to evaluate or was empty (body carries error)
401HMAC verification failed
429The workflow is paused. With idempotency_key, only a key with no live claim gets this answer (live duplicates still answer per on_duplicate)
404No workflow declares this path

Duplicate delivery (same idempotency_key within the TTL), per on_duplicate:

ModeStatusBody
return_existing (default)200{"status":"duplicate","run_id":"<original>","run_status":"<current>"}
accepted202{"status":"duplicate"} (no ids leaked)
conflict409{"status":"duplicate","run_id":"<original>"}