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_keymakes the caller'sintent_idthe claim identity: a retry of the same intent withinidempotency_ttl(7d) creates no new run and answers200 {"status":"duplicate","run_id":<original>,"run_status":...}; a body without anintent_idis a400and never starts a run request_keydedupes downstream โ the TTL is a re-use window, not retention:request_keyis 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 onrequest_key, never on the run id
Generate it
rflow new --template webhook-idempotent-handler
# or into an existing project:
rflow add workflow webhook-idempotent-handlerInputs
| key | type | default |
|---|---|---|
project_name | string | webhook-idempotent-handler |
webhook_path | string | /hooks/transfer-request |
secret_env | env_var | WEBHOOK_SECRET |
max_amount | token_amount | 10000 (whole tokens; larger requests are rejected) |
token_decimals | int | 6 (amounts arrive in base units) |
channel | string | ops |
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_letterRequired 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 retriedintent_idwithin the TTL answers with the original run instead of preparing anything twice - the prepare script enforces a hard
max_amountcap 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_letterkeeps 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-signedProduction checklist
- rotate
WEBHOOK_SECRETand share it only with the calling system - make callers send a unique, stable
intent_idper business action โ it is the trigger'sidempotency_keyclaim identity AND therequest_keyinput; the dedupe is only as good as its identity field - wire the notification channel to a real destination and alert on repeated
request_keys - put rflow's port behind your ingress/network controls (HMAC is the app layer, not the only layer)
Common modifications
- extend
prepare.jswith allowlists or a policy call to your own service - hand the prepared
txto a downstream executor โ or add asend_transaction:step plus signer/relayer (with anapproval:gate) to execute in-place, which upgrades the risk tomoney_moving - swap telegram for slack/pagerduty in the
notifications:block