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 template has no native send step, signer or relayer. Its bundled script only prepares data; custom commands run with your permissions and can make their own external calls.

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
rflow_version: 1
name: webhook-idempotent-handler
 
config:
  port: 3940
  db_connection: ${DATABASE_URL}
 
secrets:
  webhook: ${WEBHOOK_SECRET}
 
notifications:
  channels:
    ops:
      telegram:
        bot_token: ${TG_BOT_TOKEN}
        chat_id: ${TG_CHAT_ID}
 
workflows:
  webhook-idempotent-handler:
    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