Skip to main content

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:

A user saves a record in the app, which writes it to the purchase-orders table. The change fires the Event trigger of the audit workflow, which builds a log entry in a Transform and writes it with a Table Updater to the purchase-orders-audit table. The History tab in the app reads the entries from that audit log table.

  1. Your app saves a record in a table, for example purchase-orders.
  2. 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.
  3. An audit workflow turns the event into one log entry and writes it to an audit log table, for example purchase-orders-audit.
  4. 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.

ColumnWhat it holdsPrimary key
entryIdA unique ID for the log entry, built by the workflow✅
tableNameThe table the change happened in
recordKeyThe primary key of the changed record, for example the order number
actionCreate, Update or Delete
changedByThe ID of the user who made the change, from the record's __modifiedBy (empty on Delete, unless you delete through a workflow)
changedAtWhen the change was logged, in UTC
changedFieldsThe names of the fields that changed
beforeThe record before the change (empty on Create)
afterThe 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:

SettingValue
Tablepurchase-orders (the table your app edits)
ActionsCreate, Update and Delete
ExpressionLeave 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).

caution

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 status to DELETED. That is an update, so __modifiedBy and 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.

A user deletes a record in the app, which starts the delete workflow with the record key through a Manual trigger. A Transform builds a log entry with the user from context, and a Table Updater writes it to the purchase-orders-audit table as entry 1, recording who deleted the record. A REST API step then deletes the record from the purchase-orders table. The deletion fires the Event trigger of the audit workflow, which writes entry 2 to the audit log table, recording what the record contained.

The delete workflow has four steps:

  1. A Manual trigger. The app starts the workflow with the key of the record to delete, for example { "orderNumber": "PO-10482" }.

  2. 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": []
    }
    )
  3. A Table Updater that writes the entry to purchase-orders-audit.

  4. A REST API step that deletes the record with the Custom Tables API (Delete a record from a custom table):

    SettingValue
    ConnectionA Connection that uses an API Client with access to the table
    HTTP MethodDELETE
    Endpoint 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:

EntryWritten byShows
FirstThe delete workflowWho deleted the record, and when
SecondThe audit workflow, when the record is removedWhat 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 before and changedFields are 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​