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| Field | Required | Description |
|---|---|---|
contract | ✅ | A contract registry name. Factory-scoped contracts fire for every child |
name | ✅ | The event name from the ABI, e.g. Transfer |
network | ✅ | A declared network |
where | Filter expression — the run only starts when it is true | |
confirmations | 0 | Depth the event must reach before firing — see below |
run_on | confirmed | Which phase(s) fire: unconfirmed | confirmed | both (two-phase) — see below |
start_block | latest | Where the cursor starts — see backfill below |
end_block | live | live tails forever; a number makes a bounded job that exits cleanly |
throttle | Durable rate limiting for noisy triggers — see below |
The trigger context
Inside expressions, the firing event is trigger.*:
| Path | Description |
|---|---|
trigger.args.<name> | Decoded event arguments (uint256 values stay exact — see U256 semantics) |
trigger.tx_hash, trigger.log_index | Event identity |
trigger.block_number, trigger.block_hash | Position |
trigger.address | The emitting contract (useful with factory-scoped triggers) |
trigger.network, trigger.chain_id | Where |
trigger.topics | Raw log topics |
trigger.phase | unconfirmed | confirmed — which phase fired this run (see run_on) |
where
One expression, evaluated per event. Combine conditions with and / or / not —
not && / ||:
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):
| Chain | Suggested depth for irreversible actions |
|---|---|
| ethereum | 20 |
| base / arbitrum / optimism | 24 |
| polygon | 200 |
| anything else | 12 |
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 depthunconfirmed— fire the moment the event is first seen at headboth— 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 withtrigger.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: 10mThe 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 livestart_block: latest(default) — start from nowstart_block: 19000000— backfill from a specific blockend_block: live(default) — tail foreverend_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.