Triggers in Depth: Reacting to Table Events
Every workflow starts with a Trigger, and the trigger decides when the work happens. Most workflows are started by a person or a clock. The most powerful integrations, though, react to data: a record lands in a table, its status changes, and the right workflow runs within moments — with nobody clicking Run and nothing polling.
This guide gives a short tour of the trigger types, then goes deep on table event triggers: what the workflow receives, how to make it fire on exactly the change you care about, and the patterns and pitfalls that come with event-driven design.
The trigger types at a glance
| Trigger | The workflow runs when… | Typical use |
|---|---|---|
| Manual | A user clicks Run, or a system calls the workflow through the API | On-demand lookups, user-supplied input, workflows used as an API |
| Schedule | A defined time arrives (hourly, daily, weekly, …) | Nightly syncs, periodic exports, recurring reports |
| Event | A record in a Vince Platform table is created, updated, or deleted | Reacting to data changes as they happen |
External systems can also start a workflow by calling a URL — set one up on the Webhook tab of the Trigger step (Webhook Trigger) or under Administration → Integrations (Webhooks).
Rule of thumb: if you find yourself scheduling a workflow every few minutes just to check whether something has changed in a table, you want an Event trigger instead. It runs only when there is something to do, and it tells you exactly what changed.
Land, then react: the pattern behind event triggers
Event triggers shine when you split one big workflow into two small ones at a natural seam:
- A producer workflow lands data in a table — and stops there.
- Writing the table creates, updates, or deletes records.
- A consumer workflow with an Event trigger on that table reacts to exactly the change that matters.
Why split at a table?
- Decoupling. The producer doesn't know or care who reacts. Adding a second reaction later — an alert, an export — means adding a new consumer, not editing the producer.
- A visible seam. The table is a live record of what has flowed through. When something looks wrong, you open the table with Show Data instead of digging through execution logs.
- Change-level precision. A consumer can react to "status changed to Released" rather than to every write.
- A natural retry point. If a consumer fails, the record is still there — and you can make it fire again (see Re-firing a consumer).
Use a direct call instead (one workflow calling another and waiting for the result) when the caller needs the answer immediately. Use the table seam when the relationship is "something happened; interested parties should react."
Configuring an Event trigger
In the Event trigger you choose:
| Setting | What it decides |
|---|---|
| Table | Which table the workflow listens to |
| Actions | Which kinds of change fire the workflow: Create, Update, Delete — one or several |
| Expression | A JSONata condition that must be true for the workflow to run |
The trigger also offers a Filter form for building simple conditions without writing an expression. We recommend Expression: it can describe everything the form can and more (old vs new values, conditions across several properties), and it is easy to copy between workflows and review.
The workflow runs once per changed record. If a producer writes 500 records in one go, the consumer can run up to 500 times — once for each record whose change matches your actions and expression. This is the main reason to always narrow the trigger with an expression.
What the event contains
Every event carries the record as it was before the change, the record as it is now, and a summary of what changed. In an Expression, these are reached through data.:
| Path | Contains |
|---|---|
data.newData | The full record as it looks after the change |
data.oldData | The full record as it looked before the change |
data.changed.<property>.newVal | The new value of a property that changed |
data.changed.<property>.oldVal | The previous value of a property that changed |
Which parts are filled in depends on the action:
| Action | data.newData | data.oldData | data.changed |
|---|---|---|---|
| Create | The new record | Empty ({}) | Every property, with newVal only |
| Update | The record after the change | The record before the change | Only the properties whose value differs — with newVal and oldVal |
| Delete | Empty ({}) | The deleted record | Every property, with oldVal only |
Two details matter on Update:
- A property that had no value before and gets one now (for example a flag set for the first time) appears in
changedwithnewValonly. - A property that is removed appears with
oldValonly.
Nested objects and lists
changed is keyed by top-level property. When something inside an object or a list changes, the whole property is reported, and newVal and oldVal hold its complete value, whatever its type. If only the city of an order's delivery address changes, the event contains:
"changed": {
"deliveryAddress": {
"newVal": { "street": "Industrivegen 12", "postalCode": "0581", "city": "Oslo", "country": "NO" },
"oldVal": { "street": "Industrivegen 12", "postalCode": "0581", "city": "Bergen", "country": "NO" }
}
}
The same goes for an embedded list. Changing one line of an order puts the entire lines list in changed, both before and after. To react to a specific nested change, compare inside the two values:
data.changed.deliveryAddress.newVal.city != data.changed.deliveryAddress.oldVal.city
$exists(data.changed.lines) only tells you that some line changed. To find which lines, compare newVal and oldVal in a Transform inside the workflow. This is also why a "many" side that changes line by line often belongs in its own table. See Designing Tables.
Every record also carries read-only system properties maintained by the platform: __created, __createdBy, __modified, and __modifiedBy. The two ...By properties hold the ID of the user who created or last changed the record, in the form USER- followed by 32 characters. Because __modified changes on every update, it shows up in data.changed every time — so always base your expression on your own properties, never on "anything changed".
Here is what an update looks like for a synthetic purchase-orders table where a buyer releases an order:
{
"config": {
"tableName": "purchase-orders",
"primaryKeys": ["orderNumber"]
},
"data": {
"changed": {
"__modified": { "newVal": "2026-10-02T10:14:37.512Z", "oldVal": "2026-10-02T08:02:11.904Z" },
"status": { "newVal": "RELEASED", "oldVal": "DRAFT" },
"releasedBy": { "newVal": "a.jensen" }
},
"newData": {
"orderNumber": "PO-10482",
"supplier": "SUP-0091",
"warehouse": "WH1",
"status": "RELEASED",
"releasedBy": "a.jensen",
"__created": "2026-10-01T14:40:03.118Z",
"__modified": "2026-10-02T10:14:37.512Z"
},
"oldData": {
"orderNumber": "PO-10482",
"supplier": "SUP-0091",
"warehouse": "WH1",
"status": "DRAFT",
"__created": "2026-10-01T14:40:03.118Z",
"__modified": "2026-10-02T08:02:11.904Z"
}
}
}
Notice that releasedBy was set for the first time, so it has only a newVal, and that supplier and warehouse are absent from changed because they didn't change.
config.tableName and config.primaryKeys tell you which table the event came from and which properties identify the record — handy when you build messages or call another system with the record's identity.
Reading the event inside the workflow
The consumer workflow receives the whole event as the trigger's body. That means the paths are the same as in the expression, under $context.data.trigger.body, which any step can read:
| Where | How to read the new status |
|---|---|
| Event trigger Expression | data.newData.status |
| Transform step | $context.data.trigger.body.data.newData.status |
| REST API templating | {{ $context.data.trigger.body.data.newData.status }} |
Reading the event from $context.data.trigger instead of $ or body means it works in any step, not only the one straight after the trigger. See the $context Reference.
A typical consumer starts with a Transform that picks out just what it needs:
(
$order := $context.data.trigger.body.data.newData;
{
"orderNumber": $order.orderNumber,
"supplier": $order.supplier,
"warehouse": $order.warehouse,
"releasedBy": $order.releasedBy
}
)
Writing expressions that fire at the right moment
The expression is where most of the design work happens. Each example below uses the synthetic purchase-orders table.
"Status moved to X"
Fire when an order is released — and only at the moment it is released, not on every later edit of an already-released order:
| Setting | Value |
|---|---|
| Actions | Update |
| Expression | data.changed.status.newVal = 'RELEASED' |
Because data.changed contains only properties that changed, this is true exactly once per release. A later update that changes, say, the delivery date leaves status out of changed, so the expression is false and the workflow doesn't run.
If you also select Create, this expression fires for orders that are created already released — on create, every property is listed in changed with a newVal. That may be exactly what you want ("any order that becomes released, however it got there"). If you only want genuine transitions, select Update only.
Don't try to detect "this is an update" by checking that oldVal exists: a property set for the first time on an update also has only a newVal. Use the Actions setting to separate creates from updates.
"Changed from A to B"
Fire only for a specific transition — for example an order that was approved and is now cancelled, which needs to be withdrawn from the supplier:
data.changed.status.oldVal = 'APPROVED' and data.changed.status.newVal = 'CANCELLED'
A change plus a condition on the rest of the record
Combine what changed with what the record looks like now. Fire when an order is released and it belongs to warehouse WH1:
data.changed.status.newVal = 'RELEASED' and data.newData.warehouse = 'WH1'
data.newData always holds the full record after the change, so you can test any property — not just the ones that changed.
Several values at once
JSONata's in operator keeps expressions short when several values qualify:
data.changed.status.newVal in ['RELEASED', 'EXPEDITED']
Reacting to a deletion
On Delete, the record is gone from newData — read it from oldData instead:
| Setting | Value |
|---|---|
| Actions | Delete |
| Expression | data.oldData.status = 'RELEASED' |
This fires only when a released order is deleted — for example to tell the warehouse to stop preparing it. Inside the workflow, read the deleted record with $context.data.trigger.body.data.oldData.
Test an expression before relying on it. Paste a sample event (like the one above) into the JSONata exerciser, write your expression, and check that it returns true for the changes you want and false for the ones you don't.
Patterns and pitfalls
Re-firing a consumer
An update that doesn't change any of your own properties does not fire an event. Saving a record with exactly the same values — where only the platform's timestamps would move — is silently ignored.
So if a consumer failed and you want it to run again for a record, re-writing the same data is not enough. Change a real property instead. A common approach is a small counter property, such as retryCount, that you increase to request a retry — and an expression that fires on it:
data.changed.status.newVal = 'RELEASED' or $exists(data.changed.retryCount)
The self-feeding loop
A consumer that writes back to the table it listens to — for example stamping a sentToWarehouseAt property — causes another update event on the same record. That is a perfectly valid design (status-tracking tables do it on purpose), but only if the write-back cannot match the expression that fired the consumer.
- ✅ The expression tests
data.changed.status.newVal = 'RELEASED'; the consumer writes onlysentToWarehouseAt. The write-back'schangedcontains nostatus, so the consumer doesn't fire again. - ❌ No expression at all, or an expression on
data.newData.status = 'RELEASED'. The write-back still has statusRELEASEDinnewData, so the consumer fires again — and again.
Treat the event as a signal
When several changes to the same record happen in quick succession, design your consumer so it doesn't depend on processing them in a particular order. Robust consumers treat the event as a signal that something changed and act on the record's current state — or ask the source system — rather than assuming each event is the latest truth.
Keep records small
An event carries the whole record, twice on update (before and after). For very large records, the platform may deliver the event without the old record data or the change summary, so expressions on oldData or changed can't match. This matters most for deletes, where oldData is the only copy of the record.
Keep records well below the 256 KB size limit, and if a table must hold very large documents, avoid relying on oldData for its delete events. See Designing Tables for when to split large documents across tables.
Make sure the consumer can run
An Event-triggered workflow only reacts if it is Enabled. After wiring a new consumer, verify the whole chain once: change a record in the table, then check Execution Logs to see that the consumer ran and did what you expected.
Checklist
- The producer ends at the Table Updater — no downstream side effects in the same workflow
- The table's primary key is chosen deliberately — it decides what counts as a new record (Create) versus a change to an existing one (Update)
- Actions match the intent — Update only for genuine transitions
- An Expression narrows the trigger to the change that matters, based on your own properties
- Write-backs to the same table cannot match the expression
- The consumer acts on current state and doesn't depend on event order
- End-to-end verified: change a record, see the consumer run in Execution Logs
Further reading
- Event Trigger — reference for the Event trigger component
- Designing Tables — model the tables your events come from
- Audit log for your app — a complete example: log every change to a table in an audit log table
- Workflow Architecture 101 — where triggers fit in the overall shape of a workflow
- JSONata Masterclass — write the expressions and transforms used in this guide
- Table Updater — write records from the producer workflow
- Webhooks — let external systems start a workflow