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) — 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"
FieldRequiredDescription
pathRoute 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_keyDedupe 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 | conflictsee 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.

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 | conflict

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. the header is missing) 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 (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 — 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_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 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):

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 when the key holds no live claim (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>"}