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

Event trigger

Fire a workflow on a decoded onchain event.

trigger:
  event:
    contract: USDC              # name in the contracts: registry
    name: Transfer              # event name
    network: ethereum
    where: "${{ trigger.args.value > wei('1000', 6) }}"   # optional filter
    confirmations: 0            # 0 (head, default) | N | finalized
    run_on: confirmed           # unconfirmed | confirmed (default) | both
    start_block: latest         # earliest | latest | <n>
    end_block: live             # live | <n>
    throttle:                   # optional — cap noisy triggers
      threshold: { count: 5, window: 1h }
      cooldown: 10m
FieldRequiredDescription
contractA contract registry name. Factory-scoped contracts fire for every child
nameThe event name from the ABI, e.g. Transfer
networkA declared network
whereFilter expression — the run only starts when it is true
confirmations0Depth the event must reach before firing — see below
run_onconfirmedWhich phase(s) fire: unconfirmed | confirmed | both (two-phase) — see below
start_blocklatestWhere the cursor starts — see backfill below
end_blocklivelive tails forever; a number makes a bounded job that exits cleanly
throttleDurable rate limiting for noisy triggers — see below

The trigger context

Inside expressions, the firing event is trigger.*:

PathDescription
trigger.args.<name>Decoded event arguments (uint256 values stay exact — see U256 semantics)
trigger.tx_hash, trigger.log_indexEvent identity
trigger.block_number, trigger.block_hashPosition
trigger.addressThe emitting contract (useful with factory-scoped triggers)
trigger.network, trigger.chain_idWhere
trigger.topicsRaw log topics
trigger.phaseunconfirmed | confirmed — which phase fired this run (see run_on)

where

One expression, evaluated per event. Combine conditions with and / or / notnot && / ||:

where: "${{ trigger.args.sender in lists.watched_traders and trigger.args.amount0 > wei('1', 18) }}"

A where that evaluates false skips the event without creating a run. An expression error (typo'd key, bad type) never silently evaluates false — it routes to failure handling so you see it.

confirmations

How deep the event must be before the workflow fires: 0 fires at head (the default — rflow is built to be fast), N waits N blocks, finalized waits for chain finality.

Per-chain guidance (what rflow validate prints — advice only, never blocking):

ChainSuggested depth for irreversible actions
ethereum20
base / arbitrum / optimism24
polygon200
anything else12

Rule of thumb: alert at 0, pay at depth. A head-fired trigger that sends funds gets a validation warning, because a reorg can orphan the triggering event after your transaction is sent. Full discussion in Reorgs.

run_on — two-phase firing

confirmations sets the depth; run_on picks which observation(s) of the event start a run:

  • confirmed (default) — fire once the event reaches the trigger's confirmation depth
  • unconfirmed — fire the moment the event is first seen at head
  • both — fire twice for the same event: once at head, once at depth. Each phase claims its own trigger key, so both runs are individually exactly-once. Steps distinguish them with trigger.phase:
trigger:
  event:
    contract: AnyPool
    name: Swap
    network: ethereum
    confirmations: 20
    run_on: both
steps:
  - id: alert            # instant heads-up
    if: "${{ trigger.phase == 'unconfirmed' }}"
    notify: { channel: ops, message: "swap seen at head: ${{ trigger.tx_hash }}" }
  - id: mirror           # money moves only at depth
    if: "${{ trigger.phase == 'confirmed' }}"
    send_transaction: { ... }

This is the single-workflow version of the "alert at head, pay at depth" pattern. If the head-fired event is later orphaned by a reorg, the confirmed phase never comes — pair it with on_reorg: if you need to compensate for what the unconfirmed phase already did.

throttle

Cap how often a noisy trigger may claim runs. After threshold.count fired runs inside threshold.window, further matches are suppressed (skipped with a log line, the cursor still advances) until cooldown has passed:

throttle:
  threshold: { count: 5, window: 1h }
  cooldown: 10m

The window counter and the cooldown fence are persisted in Postgres (rflow.trigger_throttle), never in memory — a restart cannot reset a tripped cooldown. Only events that actually claimed a run count against the window, so duplicate deliveries never double-count.

Saturation behaviour, stated plainly: if the window has not rolled over when the cooldown expires, the next fire runs and immediately re-trips the cooldown — i.e. at most one run per cooldown period until the window resets.

(For limiting a workflow rather than a trigger, see rate_limit — note its window is in-process, unlike throttle.)

Backfill and cursors

start_block / end_block give you history and live tailing in one config:

  • start_block: earliest — backfill from the contract's first block, then go live
  • start_block: latest (default) — start from now
  • start_block: 19000000 — backfill from a specific block
  • end_block: live (default) — tail forever
  • end_block: 19100000 — bounded job: process the range, exit cleanly

Each (workflow, network) pair keeps a cursor (last processed block) in Postgres — visible in rflow ls — so restarts resume exactly where they left off and a crash never skips or re-processes a block range. Combined with the exactly-once claim, replaying an overlap is harmless: duplicate events are skipped.

To rehearse a workflow against a historical range without touching live state, use rflow replay — it runs an isolated, dry-run-by-default session over --from-block/--to-block.

One dev-chain caveat: a trigger with confirmations: N live-tailing from the head of a fresh local chain (no history at all, e.g. a just-started anvil) can panic the embedded indexer's progress tracker (an upstream start == target edge). Real chains have history; on a fresh anvil give the trigger an explicit start_block (even 1) so it has a real backfill range.

Factory-scoped triggers

Point the trigger at a factory contract and it fires for every child ever deployed:

trigger:
  event:
    contract: AnyPool        # factory: { contract: UniV3Factory, event: PoolCreated }
    name: Swap
    network: ethereum
    where: "${{ trigger.args.sender in lists.watched_traders }}"

trigger.address tells you which child emitted.

Reacting to reorgs

If a reorg orphans an event this trigger already fired on, the workflow's on_reorg: steps run with a reorg.* context — notify, compensate, whatever the situation needs.