Run a flow when records change
A POST flow can have a trigger instead of being called: it then runs by itself, in the background, after records in the collections you name are created, changed or deleted. The usual use is a derived collection: data from several collections joined once, when the sources change, so a page shows it with one query instead of joining on every visit.
Set a trigger
Ask the assistant (“rebuild speaker_events whenever an event or speaker changes”) or, as an admin, add on_record_change with create_flow or set_flow_trigger:
{ "name": "rebuild-x", "steps": [ ... ],
"on_record_change": { "collections": ["events", "speakers"], "events": ["create", "update", "delete"] } }
| Field | What it does |
|---|---|
collections | The record collections to watch. Every write counts: flow steps (store_record, update_record including batch, delete_record, delete_records), the record tools and the studio. |
forms | Form flows whose saved entries (store_submission) to watch; each new entry is a create. Up to 10 collections and forms together. |
events | Any of create, update, delete, or ["*"] for all three (the default). |
imports | true: after a bulk import (a site import or import_records), run once per collection it changed, with the event import (no single record). Off by default, so imports run nothing. |
Replacing a flow's steps keeps its trigger; "on_record_change": null removes it. get_flow and list_flows show the trigger, the flow's page in the studio shows “Runs when”, and list_flow_triggers lists every trigger with its recent runs (queued, running, ok, failed or skipped, with tries, error and duration).
What the flow sees
Trigger flows can use these values anywhere {{input.*}} works (never in cookies or redirects):
| Value | Meaning |
|---|---|
{{trigger.event}} | create, update, delete or import |
{{trigger.collection}} | The collection (for forms: the form flow's name) |
{{trigger.record_id}} | The record's id (the entry id for forms; empty for import) |
{{trigger.record.x}} | The record as saved (empty after a delete). A lone "{{trigger.record}}" keeps the whole record |
{{trigger.old.x}} | The record before an update or delete (empty for a create) |
{{trigger.source}} | records or submissions |
{{trigger.depth}}, {{trigger.attempt}} | How deep in a chain of triggered flows (1 = started by a person or an ordinary flow) and which try this is (1–5) |
How triggered runs work
- After, never before. The change is saved first and the run is queued with it, so a change is never lost and a failing run never undoes it. A trigger can't block or change a write.
- About a second later the flow runs like a scheduled run: no visitor, up to 60 seconds. Several changes to the same record within that second become one run with the newest data (a create and then a delete run nothing). A record that keeps changing still runs within 10 seconds.
- Retries. A run fails when the flow answers with status 400 or more, errors or runs out of time. It is tried again after 30 seconds, 2 minutes, 10 minutes and 1 hour (5 tries in all), then marked failed. End your flow with
respondand a status so failures show. - Runs for flows that were turned off, deleted or lost their trigger are skipped: not run and not charged.
- Offline sites. While a site is taken offline its triggers are paused: changes made in the meantime start no runs, and runs still waiting are skipped (not charged). They work again as soon as the site is back online.
- No loops. A flow's own writes never trigger it again, nor any flow earlier in the same chain (if A changes
band B changesa, A runs, B runs, and it stops there). A chain is at most 3 levels deep; a 4th is skipped and shown in the run list. - Limits: 20 trigger flows per site, 10 collections and forms each, and 1,000 waiting runs per site (more are counted as dropped in
list_flow_triggers). Finished runs are listed for 7 days (the last 100 per flow) and keep no record data. - No public address. Visitors can't call a trigger flow:
/api/flows/<name>answers “not found”, so nobody can hand it a made-up change, and you don't need a form or link for it. Because there is no visitor, it can't use sessions, cookies, spam or signature checks (verify_*),store_submission, uploads, payments or page levels; steps that set a visitor's level or groups need anemail, such as"{{trigger.record.email}}". - A site export or import copies the trigger with its flow, and it is active on the new site. Records brought in by the import only start runs for triggers with
imports: true.
Example: an events page with speaker details, from one query
Events ({title, starts_at, speaker_id}) and speakers ({name, bio, photo}) are edited separately. The /speakers page should show every speaker with their upcoming events. Instead of a page flow that queries both on every visit, keep a collection speaker_events (one record per speaker: {speaker, events: [...]}) up to date:
{ "name": "rebuild-speaker-events",
"description": "Keeps speaker_events (each speaker with their events) up to date.",
"on_record_change": { "collections": ["events", "speakers"] },
"steps": [
{ "type": "query_records", "id": "sp", "collection": "speakers", "where": [{ "path": "_id", "value": "{{trigger.record_id}}" }],
"when": { "value": "{{trigger.collection}}", "equals": "speakers" } },
{ "type": "query_records", "id": "esp", "collection": "speakers", "where": [{ "path": "_id", "value": "{{trigger.record.speaker_id}}" }],
"when": { "value": "{{trigger.record.speaker_id}}", "exists": true } },
{ "type": "query_records", "id": "ev", "collection": "events", "where": [{ "path": "speaker_id", "value": "{{steps.esp.json.records.0.id}}" }],
"sort": [{ "path": "starts_at" }], "limit": 100, "when": { "value": "{{steps.esp.json.records.0.id}}", "exists": true } },
{ "type": "store_record", "collection": "speaker_events", "record_id": "{{steps.esp.json.records.0.id}}",
"data": { "speaker": "{{steps.esp.json.records.0.data}}", "events": "{{steps.ev.json.records}}" },
"when": { "value": "{{steps.esp.json.records.0.id}}", "exists": true } },
{ "type": "query_records", "id": "oldsp", "collection": "speakers", "where": [{ "path": "_id", "value": "{{trigger.old.speaker_id}}" }],
"when": { "value": "{{trigger.old.speaker_id}}", "exists": true } },
{ "type": "query_records", "id": "oldev", "collection": "events", "where": [{ "path": "speaker_id", "value": "{{trigger.old.speaker_id}}" }],
"sort": [{ "path": "starts_at" }], "limit": 100, "when": { "value": "{{steps.oldsp.json.records.0.id}}", "exists": true } },
{ "type": "store_record", "collection": "speaker_events", "record_id": "{{steps.oldsp.json.records.0.id}}",
"data": { "speaker": "{{steps.oldsp.json.records.0.data}}", "events": "{{steps.oldev.json.records}}" },
"when": { "value": "{{steps.oldsp.json.records.0.id}}", "exists": true } },
{ "type": "query_records", "id": "spev", "collection": "events", "where": [{ "path": "speaker_id", "value": "{{trigger.record_id}}" }],
"sort": [{ "path": "starts_at" }], "limit": 100, "when": { "value": "{{steps.sp.json.records.0.id}}", "exists": true } },
{ "type": "store_record", "collection": "speaker_events", "record_id": "{{trigger.record_id}}",
"data": { "speaker": "{{trigger.record}}", "events": "{{steps.spev.json.records}}" },
"when": { "value": "{{steps.sp.json.records.0.id}}", "exists": true } },
{ "type": "delete_record", "collection": "speaker_events", "record_id": "{{trigger.record_id}}",
"when": { "value": "{{trigger.collection}}-{{trigger.event}}", "equals": "speakers-delete" } }
] }
- When an event changes, its speaker's group is rebuilt (steps 2–4), and if the event moved from another speaker or was deleted, that speaker's group too (steps 5–7).
- When a speaker changes, their group is rebuilt with the new details (steps 1, 8 and 9); a deleted speaker's group is removed (step 10).
- Lone values such as
"{{trigger.record}}"and"{{steps.ev.json.records}}"keep their JSON, soeventsis a real list.
The page then needs a single query. Its GET data flow:
[ { "type": "query_records", "id": "q", "collection": "speaker_events",
"sort": [{ "path": "speaker.name" }], "limit": 100 },
{ "type": "respond", "json": { "speakers": "{{steps.q.json.records}}" } } ]
and in its template:
{{#flow.speakers}}<h2>{{data.speaker.name}}</h2>
{{#data.events}}<p>{{data.title}}, {{data.starts_at}}</p>{{/data.events}}
{{/flow.speakers}}
The same pattern fits any page that joins data: a staff directory with departments, products with their category, sermons with their series. Save the sources as usual and let a trigger build the page's shape once.
Price
Each triggered run, and each retry, is one flow call: 3.01 credits per 1,000, the same as any flow call. Skipped runs are free, and there is no other charge.