Sherman Web Design Docs: switch app
Open the studio

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"] } }
FieldWhat it does
collectionsThe 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.
formsForm flows whose saved entries (store_submission) to watch; each new entry is a create. Up to 10 collections and forms together.
eventsAny of create, update, delete, or ["*"] for all three (the default).
importstrue: 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):

ValueMeaning
{{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 respond and 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 b and B changes a, 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 an email, 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, so events is 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.

Updated October 31, 2026