The admin audit trail

Every column in admin_audit_log and what it answers, exactly which requests are recorded and which are not, and how to settle a chargeback or a staff dispute from a row.

8 min readUpdated 6 August 2026audit, compliance, disputes, forensics, admin

/admin/system/auditSystem → System Monitoring → Audit Trail. It reads one table, admin_audit_log, and it is the only place the platform can answer "who approved this payout, and what did they say their reason was".

Reading it needs one permission key: access.admin.audit. There is no separate view. key — the same key gates the page, the API and the per-record Audit Trail tabs described below.

The screen ships with create, edit and delete switched off, and there is no write endpoint behind it — the only route on this path is a GET. The table is written by the request pipeline (handler/Routes.ts) and by nothing else. The model carries no deletedAt either, so there is no bin and no restore. A trail whose rows can be edited from the panel is not evidence, so the ability to edit them was never built.

What a row holds

The default row is who did what, to which record, and why, left to right. Everything else is one click away — expand a row, or open the view dialog.

Column What it answers
createdAt When. The list is newest-first by default, because a trail is read backwards from the incident.
userIdAdmin Who. Joined to the user record, so the row shows the name, email and avatar rather than a UUID.
title What. The route's own logTitle — "Bulk withdrawal decision", "Update KYC application", "Block user", "Update Wallet Balance".
module Which area. The route's logModule. See the table below.
status SUCCESS or ERROR. Refusals are recorded as well as successes.
reason The operator's own words. The single most valuable column on the table.
targetId Record — the record the action was aimed at. Paste an id here to get everything that ever touched it.
method and path Which endpoint, with the query string stripped and the path truncated at 255 characters.
error The failure message, when status is ERROR.
ip Where the action came from.
durationMs How long the request took.
requestId Ties the row back to the console trace for the same request.

Two more columns are stored on the row but are not rendered anywhere in the panel:

  • targetIds — every id a bulk route was aimed at. targetId holds the first of them so the per-record lookup still resolves; this holds the whole list, up to 500 ids.
  • steps — the narrative the handler emitted as it ran ("Approving <id>", "the claim succeeded", "the email went out"). It is captured at decision time and can be read from the database, but no screen displays it.

Where reason comes from

The trail does not invent it. It is lifted from whichever of these the request carried, in this order: a reason query parameter, then reason, message, adminNotes or metadata.message in the body. Trimmed and capped at 2,000 characters.

That covers all three of the places an operator types one: the data table's destructive-action dialog (query parameter), the decision dialog on the deposit, withdrawal and transfer queues (body), and the hand-rolled block and reject dialogs. A withdrawal rejection cannot be saved without one — the endpoint refuses a reason shorter than three characters — so a REJECTED withdrawal with no reason on the trail is not something you should ever see.

Deletes say which kind of delete they were

Soft delete, restore and permanent purge all go through the same DELETE handler, separated only by a query flag. The trail qualifies the title rather than recording all three under the same words:

  • Bulk delete announcements — a soft delete.
  • Bulk delete announcements (restore)restore=true.
  • Bulk delete announcements (permanent)force=true.

The modules

module is whatever the route declared, not a fixed enumeration, so treat this as a map rather than a complete list. These are the ones you will actually see.

Module Covers
ADMIN_FIN Deposits, withdrawals, wallets, transactions, transfers. The busiest module and the one disputes come from.
ADMIN_CRM Users, roles, permissions, KYC applications.
ADMIN_SYS System screens — cron, extensions, updates, database.
SETTINGS Platform settings saves, and the settings cache.
GEO Geo restrictions, geo policy and the geo access log.
ADMIN_SUP Support tickets.
ADMIN_API API key management.
ADMIN_ECO, ADMIN_FUTURES, ADMIN_BINARY, ADMIN_DEX The order books and their markets.
ADMIN_AFFILIATE, ADMIN_ICO, ADMIN_STAKE, ADMIN_NFT, ADMIN_ECOM, ADMIN_COPY, ADMIN_MM, ADMIN_FOREX, ADMIN_FX, ADMIN_AI, … One per extension admin.

The settings save declares logModule: "SETTINGS", not ADMIN_SYS. Filtering Module to ADMIN_SYS to find out who changed a fee or a switch returns nothing at all. Filter to SETTINGS — or filter by title instead, which for that route is "Update application settings".

What produces a row, and what does not

Four conditions, all of which must hold:

  1. The route declares logModule and logTitle. A route that declares neither is invisible here — not filtered out, simply never offered to the trail. In the core admin tree that is five routes out of 223 mutations: the geo-settings preflight check and the four notification test-send endpoints.
  2. The method is POST, PUT, PATCH or DELETE. Reads are excluded deliberately. So "who looked at this customer" is not a question this table answers — that would bury the twelve rows a compliance question needs under a million page loads.
  3. The path starts with /api/admin/. Customer-side writes — every trade, deposit and transfer — are excluded. Those have their own ledgers in transaction and wallet_audit_log. This table watches the operator's door.
  4. The route has not opted out with audit: false. That flag is set on routes that cannot change persistent state: version checks, connectivity probes, credential tests, dry runs, and queries that are POSTs only because their filter payload will not fit in a query string. Around two dozen routes carry it. Without it, the extension page's automatic update-check probes accounted for 89 of the table's 94 rows.

Writing a row never fails a request. It is fire-and-forget with a swallowed error, because an audit write that could fail a withdrawal would teach operators to retry — and the retry is the one that pays out twice.

Consequences worth knowing before you rely on it

  • userId can be null. A few logged routes run without a signed-in administrator (the installer, cron-triggered maintenance). Those rows show System in the Admin column rather than being dropped.
  • The trail outlives the account. There is no foreign key on userId, on purpose — deleting an admin must not cascade away the evidence of what they did. A row whose actor no longer exists still shows the action; it just cannot render a name.
  • One request is one row, even for a bulk action. Approving forty withdrawals writes a single row.
  • A partly-failed batch is recorded as SUCCESS. The bulk withdrawal endpoint approves each id independently and reports which ones failed in its response; only a batch where nothing moved throws, and only then does the row read ERROR. So SUCCESS on a bulk row means "the request was accepted", not "all forty paid out".

The weekly review

Filter Status to ERROR. That is the whole routine, and it takes a minute.

What you are looking for is a run — the same admin, the same endpoint, several times close together. A single failure is usually a stale row (someone approved a withdrawal that had already been decided) and needs nothing. A run is one of two things, and you can tell them apart from the error column:

  • Forbidden - You do not have permission to access this — a missing permission key. Somebody was given a role that does not cover the work they were asked to do, and they have been failing at it since. Fix the role; see Onboarding and offboarding an admin.
  • Anything else, repeated — an incident. Six failed attempts to approve the same withdrawal is the shape of a payout that will not go through, and the error text names the cause. Nothing else in the product surfaces this: the operator saw one red toast each time and moved on.

Expand the row for path, ip, requestId and durationMs. The requestId is what you hand to whoever is reading the backend console log, so the two can be lined up.

Answering a dispute

A chargeback, or "an admin took money out of my account", is answered from reason — the justification the operator gave at the time of the decision, before there was anything to defend.

You usually do not need the global screen at all. The Audit Trail tab is already mounted on the record, pre-filtered to it:

Where the dispute is about Open
A withdrawal /admin/finance/withdraw/log/{id}Audit Trail
A deposit /admin/finance/deposit/log/{id}Audit Trail
An internal transfer /admin/finance/transfer/{id}Audit Trail
The customer's account as a whole /admin/crm/user/{id}Audit Trail

Each of those shows the administrative actions against that record, newest first, and — where a wallet is involved — the balance ledger beside it, so you can see the arithmetic as well as the decision.

To do it from the global screen instead:

  1. Get the record's id. On the transaction screen it is the id column, not the reference id.

  2. Open /admin/system/audit and filter Record to that id. targetId is indexed, so this is exact rather than a scan.

  3. Read reason on every row that comes back. Then expand for the actor's IP and the request id if the dispute is about who, not why.

targetId holds only the first id of a bulk action; the rest are in targetIds, which no screen displays. So a withdrawal that was approved as part of a multi-row selection, and was not first in that selection, will not be found by filtering Record to its id.

When the record filter comes back empty and you know a decision was made, search by the customer's date instead: filter createdAt to the day, and look for the matching title — "Bulk withdrawal decision", "Bulk deposit decision". Single-row decisions are unaffected, because a one-row selection puts that one id in targetId.

On this table userId is the administrator who acted and targetId is the record acted upon. Filtering a customer's id into the Admin column shows what that customer did as an administrator, which for a customer is nothing — the panel looks empty rather than wrong.

What the trail does not cover

Say this plainly to anyone who asks the table for more than it holds.

  • Reads. Nobody's viewing is recorded. There is no "who opened this KYC application" answer here.
  • Customer actions. A user's own trades, deposits, transfers and logins are not on this table. Their sign-ins, IPs and devices are on /admin/crm/user/{id}Activity; their money is in the transaction ledger and the wallet audit log.
  • Direct database writes. A row changed with SQL leaves nothing here. This records requests, not changes.
  • Changes made before the table existed. It starts when the install starts writing it.

Retention and volume

Nothing prunes this table. No scheduled job deletes from it, there is no retention setting, and there is no purge button — by design, for the same reason there is no delete endpoint.

That is affordable because of how narrow the write rule is: an admin panel generates tens to hundreds of mutations a day, not millions. The table carries four indexes — createdAt, userId + createdAt, targetId and module — so the queries the screen makes stay fast as it grows.

It is in the MySQL dump, so it is covered by Backup and restore along with everything else. If your retention policy requires the trail to be trimmed, that is a database operation you perform deliberately and record — not something the panel will do for you.