Audit log for your app
When an app lets users change data — approve an order, edit a price, delete a supplier — you often need to answer later: who changed this, when, and what was it before? This page shows how to keep that history for the records your app changes, and how to show it inside the app.
The platform's own Audit log records changes to the platform itself, such as users, roles, workflows and app versions. It does not record changes to the data inside your tables. For that, you build a small audit log of your own, as described below.
How it works
The idea is to let the table tell you when something changed, and have a separate workflow write it down:
- Your app saves a record in a table, for example
purchase-orders. - The change fires an Event trigger on that table. An Event trigger starts a workflow every time a record is created, updated or deleted, and hands it the record before and after the change.
- An audit workflow turns the event into one log entry and writes it to an audit log table, for example
purchase-orders-audit. - The app reads the audit log table to show a record's history.
The app itself doesn't have to remember to log anything: every change to the table is logged, whichever screen made it. Changes to the same table from other places, such as another workflow or VXL Live, are logged too.
Who made the change comes from the record itself. Every record in a table has a __modifiedBy field, filled in by the platform with the ID of the user who last changed it, in the form USER- followed by 32 characters. Users can't set it themselves, and you don't need to add anything to your app for it.
The log stores user IDs, not names or email addresses. To see who an ID belongs to, look the user up on the Users page.
Step 1 — Create the audit log table
Create a new table, for example purchase-orders-audit, with these columns. See Create a table.
| Column | What it holds | Primary key |
|---|---|---|
entryId | A unique ID for the log entry, built by the workflow | ✅ |
tableName | The table the change happened in | |
recordKey | The primary key of the changed record, for example the order number | |
action | Create, Update or Delete | |
changedBy | The ID of the user who made the change, from the record's __modifiedBy (empty on Delete, unless you delete through a workflow) | |
changedAt | When the change was logged, in UTC | |
changedFields | The names of the fields that changed | |
before | The record before the change (empty on Create) | |
after | The record after the change (empty on Delete) |
Keep the default update type, Upsert. Every entry gets its own entryId, so entries are only ever added, never overwritten.
Step 2 — Build the audit workflow
Create a workflow with three steps: Trigger → Transform → Table Updater.
The trigger
Use an Event trigger:
| Setting | Value |
|---|---|
| Table | purchase-orders (the table your app edits) |
| Actions | Create, Update and Delete |
| Expression | Leave empty to log every change |
An update that doesn't change any real value, such as saving a record without editing it, doesn't fire the trigger, so it isn't logged. That is usually what you want.
The Transform
Add a Transform step with this expression. It works for any table: it reads the table name and primary key from the event itself.
(
$e := $context.data.trigger.body;
/* Drop the platform's own fields, such as __created and __modified */
$clean := function($o) { $sift($o, function($v, $k) { $substring($k, 0, 2) != "__" }) };
$before := $clean($e.data.oldData);
$after := $clean($e.data.newData);
$record := $boolean($after) ? $after : $before;
$action := $boolean($before) ? ($boolean($after) ? "Update" : "Delete") : "Create";
$key := $join($e.config.primaryKeys.$string($lookup($record, $)), "|");
$at := $now();
{
"entryId": $e.config.tableName & "|" & $key & "|" & $at,
"tableName": $e.config.tableName,
"recordKey": $key,
"action": $action,
"changedBy": $e.data.newData.__modifiedBy,
"changedAt": $at,
"changedFields": [$keys($clean($e.data.changed))],
"before": $before,
"after": $after
}
)
changedBy is read from the record after the change, so on a Delete it stays empty: see Who deleted a record? below.
For an approved order, the entry looks like this:
{
"entryId": "purchase-orders|PO-10482|2026-10-15T09:12:44.318Z",
"tableName": "purchase-orders",
"recordKey": "PO-10482",
"action": "Update",
"changedBy": "USER-4f8a2c91b7d34e0a9c6e1f52d8b3a7c0",
"changedAt": "2026-10-15T09:12:44.318Z",
"changedFields": ["status"],
"before": { "orderNumber": "PO-10482", "status": "DRAFT" },
"after": { "orderNumber": "PO-10482", "status": "RELEASED" }
}
The Table Updater
Add a Table Updater step that writes to purchase-orders-audit.
Save the workflow and make sure it is Enabled. Then change a record in the app and check two things: the workflow's run in Execution Logs, and the new entry in the audit log table (Show Data).
Never put an Event trigger on the audit log table itself, and never let the audit workflow write to the table it listens to. Either would make the workflow fire on its own writes, over and over. See The self-feeding loop.
Step 3 — Show the history in the app
Add the audit log table to the app as a table resource, for example with the alias order_audit, and prompt the app to show it:
On the order detail page, add a "History" tab. List the entries from order_audit where recordKey is the order number, newest first. Show changedAt as local date and time, changedBy, action and changedFields. When the user clicks an entry, show the before and after values of each changed field side by side. The history is read-only: no buttons to edit or delete entries.
To show the history only to some users, such as managers, use In-App Roles.
Good to know
Who deleted a record?
The audit log can't tell you. A Delete event only has the record as it was before it was deleted, and its __modifiedBy is the last person who edited it, not the person who deleted it. That is why the Transform leaves changedBy empty on a Delete.
There are two ways to find out anyway:
- Mark records as deleted instead of removing them, for example by setting
statustoDELETED. That is an update, so__modifiedByand the log entry show who did it. Prompt the app to hide records marked as deleted. This is the simplest option. - Delete through a workflow, as described next. Use this when you truly need to know who deleted each record.
Delete through a workflow
Instead of letting the app delete records in the table directly, let it run a delete workflow. A workflow knows which user ran it: $context.user holds the signed-in user's profile (see user: who ran the workflow). So the workflow can write who deleted the record to the audit log, with the user set by the platform, not by the app, and then delete the record.
You only need the workflow for deletes. The app keeps creating and updating records directly, and the audit workflow above keeps logging those changes.
The delete workflow has four steps:
-
A Manual trigger. The app starts the workflow with the key of the record to delete, for example
{ "orderNumber": "PO-10482" }. -
A Transform that builds the log entry, in the same shape as the audit workflow's entries:
($key := $context.data.trigger.body.orderNumber;$at := $now();{"entryId": "purchase-orders|" & $key & "|" & $at,"tableName": "purchase-orders","recordKey": $key,"action": "Delete","changedBy": $context.user.userId,"changedAt": $at,"changedFields": []}) -
A Table Updater that writes the entry to
purchase-orders-audit. -
A REST API step that deletes the record with the Custom Tables API (Delete a record from a custom table):
Setting Value Connection A Connection that uses an API Client with access to the table HTTP Method DELETEEndpoint URL {{{ $context.env.apiUrl }}}/v1/custom-tables/data/purchase-orders/{{{ $context.data.trigger.body.orderNumber }}}
Keep Delete in the Actions of the audit workflow's Event trigger. Each delete then shows up in the log as two entries, a moment apart:
| Entry | Written by | Shows |
|---|---|---|
| First | The delete workflow | Who deleted the record, and when |
| Second | The audit workflow, when the record is removed | What the record contained, in before |
If the delete itself fails, only the first entry appears and the record is still in the table. A REST API step doesn't stop the workflow when the request fails, so check the run in Execution Logs.
Run the workflow once with a test record, so App Builder can learn its input (see The 1-run requirement). Then add it to the app as a workflow resource, for example with the alias delete_order, and prompt the app to use it:
To delete a purchase order, run delete_order with its orderNumber. Never delete records from purchase_orders directly.
More things to know
- Many changes at once. The audit workflow runs once per changed record. If the app updates 200 records in one go, you get 200 log entries and 200 workflow runs.
- Large records. For very large records, the event may arrive without the record before the change, so
beforeandchangedFieldsare empty. Keep records well below the 256 KB limit; see Keep records small. - One audit table or several. The Transform includes
tableName, so several tables can share one audit log table. Each table needs its own audit workflow, since an Event trigger listens to one table, but they can all use the same Transform. - Retention. The audit log table grows with every change. Tables handle millions of records, but if you only need, say, two years of history, plan a scheduled workflow that removes older entries.
Further reading
- Triggers in Depth — the full event shape, expressions and pitfalls
- Event Trigger — reference for the trigger used here
- Designing Tables — primary keys and record size
- Security & Roles — the other logs available for apps