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 idempotent handler

Template: webhook-idempotent-handler ยท category: offchain ยท risk: prepares_tx

An HMAC-authenticated webhook feeds a project-owned node script that validates the request, derives a deterministic dedupe key, and prepares an ERC20 transfer as data; rflow notifies a channel with the decision. prepares_tx honestly: transaction calldata is produced, but the project has no signer and no relayer โ€” nothing in it can broadcast.

When to use it

  • accept transfer/payout requests from another system without giving that system a key
  • put validation + policy (caps, allowlists) in YOUR code while rflow owns authentication, durability and the audit trail
  • the safe precursor to a relaying flow: prove the intake path first, add the send step later

The idempotency story

Two layers, each owning a different window:

  • the trigger dedupes deliveries โ€” idempotency_key makes the caller's intent_id the claim identity: a retry of the same intent within idempotency_ttl (7d) creates no new run and answers 200 {"status":"duplicate","run_id":<original>,"run_status":...}; a body without an intent_id is a 400 and never starts a run
  • request_key dedupes downstream โ€” the TTL is a re-use window, not retention: request_key is a pure function of the body (sha256(intent_id | to | amount) truncated), so a post-TTL replay yields the same key on a new run; every notification leads with it, and anything consuming the prepared payload must dedupe on request_key, never on the run id

Generate it

rflow new --template webhook-idempotent-handler
# or into an existing project:
rflow add workflow webhook-idempotent-handler

Inputs

keytypedefault
project_namestringwebhook-idempotent-handler
webhook_pathstring/hooks/transfer-request
secret_envenv_varWEBHOOK_SECRET
max_amounttoken_amount10000 (whole tokens; larger requests are rejected)
token_decimalsint6 (amounts arrive in base units)
channelstringops

Generated YAML (the shape)

# recipe: partial
trigger:
  webhook:
    path: /hooks/transfer-request
    auth: hmac                       # rflow verifies the body signature
    secret: "${{ secrets.webhook }}"
    idempotency_key: "${{ body.intent_id }}"  # retries dedupe BEFORE a run exists
    idempotency_ttl: 7d
    on_duplicate: return_existing    # 200 + the original run id
steps:
  - id: prepare   # validate + dedupe-key + prepare (never signs or sends)
    command:
      run: "node ./scripts/prepare.js"
      timeout: 10s
      output: json
      input: { intent_id: "${{ trigger.args.intent_id }}", to: "${{ trigger.args.to }}", amount: "${{ trigger.args.amount }}", ... }
  - id: alert     # the message leads with the request_key so duplicates show
    notify:
      channel: ops
      message: "[${{ steps.prepare.output.request_key }}] transfer request ... -> ${{ steps.prepare.output.decision }}"
on_failure: dead_letter

Required env vars

DATABASE_URL, the HMAC shared secret (default WEBHOOK_SECRET), and TG_BOT_TOKEN / TG_CHAT_ID for the notification channel โ€” all listed in the generated .env.example. node must be on PATH.

Safety notes

  • the webhook is HMAC-authenticated (auth: hmac) โ€” unsigned/garbled bodies never start a run
  • caller retries are deduped at the trigger (idempotency_key) โ€” a retried intent_id within the TTL answers with the original run instead of preparing anything twice
  • the prepare script enforces a hard max_amount cap and address/amount shape checks before anything is prepared
  • no signer, no relayer: a compromised caller can at worst generate rejected requests and notifications
  • on_failure: dead_letter keeps failed deliveries replayable

Run it locally

docker compose up -d
rflow validate
# rehearse a delivery without HTTP:
rflow test webhook-idempotent-handler --fixture fixtures/webhook-request.json
rflow start
# live: POST {"intent_id","to","amount"} to the path, HMAC-SHA256-signed

Production checklist

  1. rotate WEBHOOK_SECRET and share it only with the calling system
  2. make callers send a unique, stable intent_id per business action โ€” it is the trigger's idempotency_key claim identity AND the request_key input; the dedupe is only as good as its identity field
  3. wire the notification channel to a real destination and alert on repeated request_keys
  4. put rflow's port behind your ingress/network controls (HMAC is the app layer, not the only layer)

Common modifications

  • extend prepare.js with allowlists or a policy call to your own service
  • hand the prepared tx to a downstream executor โ€” or add a send_transaction: step plus signer/relayer (with an approval: gate) to execute in-place, which upgrades the risk to money_moving
  • swap telegram for slack/pagerduty in the notifications: block