Skip to main content

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​

TriggerThe workflow runs when…Typical use
ManualA user clicks Run, or a system calls the workflow through the APIOn-demand lookups, user-supplied input, workflows used as an API
ScheduleA defined time arrives (hourly, daily, weekly, …)Nightly syncs, periodic exports, recurring reports
EventA record in a Vince Platform table is created, updated, or deletedReacting 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 ends with a Transform and a Table Updater that writes records to the purchase-orders table; the record change fires the Event trigger of the consumer workflow, which runs a Transform and a REST API step to react to the change.

  1. A producer workflow lands data in a table — and stops there.
  2. Writing the table creates, updates, or deletes records.
  3. 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:

SettingWhat it decides
TableWhich table the workflow listens to
ActionsWhich kinds of change fire the workflow: Create, Update, Delete — one or several
ExpressionA 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.

note

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.:

PathContains
data.newDataThe full record as it looks after the change
data.oldDataThe full record as it looked before the change
data.changed.<property>.newValThe new value of a property that changed
data.changed.<property>.oldValThe previous value of a property that changed

Which parts are filled in depends on the action:

Actiondata.newDatadata.oldDatadata.changed
CreateThe new recordEmpty ({})Every property, with newVal only
UpdateThe record after the changeThe record before the changeOnly the properties whose value differs — with newVal and oldVal
DeleteEmpty ({})The deleted recordEvery 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 changed with newVal only.
  • A property that is removed appears with oldVal only.

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:

WhereHow to read the new status
Event trigger Expressiondata.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:

SettingValue
ActionsUpdate
Expressiondata.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.

caution

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:

SettingValue
ActionsDelete
Expressiondata.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.

tip

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 only sentToWarehouseAt. The write-back's changed contains no status, so the consumer doesn't fire again.
  • ❌ No expression at all, or an expression on data.newData.status = 'RELEASED'. The write-back still has status RELEASED in newData, 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​