Audit History API
The read side of the append-only audit log. The standalone viewer (GET /admin/audit-logs) and the per-entity history routes share one response shape. Rows are written once and never changed — a trigger rejects UPDATE and DELETE on the table.
Reads are backed by two composite indexes on the audit log: one on (entity_type, entity_id, created_at) for the entity history panels, and one on (actor_id, created_at) for actor-filtered views.
Entry Shape
{
"id": "f6a7b8c9-d0e1-2345-fabc-678901234567",
"createdAt": "2026-06-10T12:00:00.000Z",
"actor": { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "Sara Officer" },
"action": "officer.delivery_email_resent",
"entityType": "activation",
"entityId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"changedFields": ["customerId", "sequence"]
}| Field | Type | Description |
|---|---|---|
id | string (uuid) | Audit row ID |
createdAt | string | ISO 8601 timestamp |
actor | object | null | The user who triggered the event, null for system actions |
action | string | The event string, see Audit Actions |
entityType | string | Type of entity affected (e.g. activation, user, company) |
entityId | string (uuid) | ID of the affected entity |
changedFields | string[] | Sorted union of the keys in oldValues and newValues |
oldValues | object | null | Previous state. Owner only |
newValues | object | null | New state. Owner only |
Redaction Rule
Every viewer sees changedFields, so they can see what moved without seeing the values. oldValues and newValues are included only for the owner (super_admin). The rule is identical on the standalone viewer and every per-entity history route.
Pagination
Keyset (cursor-based) pagination over (createdAt, id) descending, the same pattern as the other admin lists.
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | (none) | Full-precision createdAt|id from the previous page's nextCursor, e.g. 2026-06-10 12:00:00.123456+00|f6a7b8c9-d0e1-2345-fabc-678901234567 |
limit | integer | 50 | Page size from 1 to 100 |
The response envelope is { items, nextCursor, hasMore }. nextCursor is null on the last page.
GET /admin/audit-logs
The standalone audit viewer. Filter and paginate the whole log, newest first.
Requires the audit area (View).
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
entityType | string | (none) | Restrict to one entity type |
entityId | string (uuid) | (none) | Restrict to one entity |
actorId | string (uuid) | (none) | Restrict to one actor |
action | string | (none) | Restrict to one action string |
from | ISO datetime | (none) | Inclusive lower bound on createdAt |
to | ISO datetime | (none) | Inclusive upper bound on createdAt |
cursor | string | (none) | createdAt|id cursor from the previous page |
limit | integer | 50 | Page size from 1 to 100 |
Example
GET /admin/audit-logs?entityType=esim_store_order&action=store.order_requeued&limit=20
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...Response
200 OK
{
"items": [
{
"id": "f6a7b8c9-d0e1-2345-fabc-678901234567",
"createdAt": "2026-06-10T12:00:00.000Z",
"actor": { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "Sara Officer" },
"action": "store.order_requeued",
"entityType": "esim_store_order",
"entityId": "b8c9d0e1-f2a3-4567-bcde-890123456789",
"changedFields": ["jobId", "orderId", "userId"],
"oldValues": null,
"newValues": {
"orderId": "b8c9d0e1-f2a3-4567-bcde-890123456789",
"userId": "e5f6a7b8-c9d0-1234-efab-567890123456",
"jobId": "b8c9d0e1-f2a3-4567-bcde-890123456789-fulfill"
}
}
],
"nextCursor": "2026-06-10T12:00:00.000Z|f6a7b8c9-d0e1-2345-fabc-678901234567",
"hasMore": true
}Error Responses
| Code | Status | Condition |
|---|---|---|
VALIDATION_ERROR | 400 | Invalid UUID, datetime, cursor, or limit |
AUTH_REQUIRED | 401 | Missing token (an invalid or expired token returns AUTH_SESSION_EXPIRED) |
AUTH_FORBIDDEN | 403 | Caller does not hold the audit area |
Entity History Routes
Each returns the audit rows for one entity, newest first, in the same { items, nextCursor, hasMore } envelope. All accept only cursor and limit as query parameters, under the pagination rules above. The redaction rule applies everywhere: changedFields for every viewer, oldValues/newValues for the owner.
| Route | Entity type | Access |
|---|---|---|
GET /admin/customers/:id/history | user | The customers area (View) — see Customers |
GET /admin/store/orders/:id/history | esim_store_order | The store area (View) — see eSIM Store |
GET /admin/companies/:id/history | company | Role + b2b View, the same gate as the rest of the company oversight routes — see B2B Admin |
Audit Actions
The action strings the app writes today. For staff user changes, company registration, company invites, roster changes, link minting, quote counters, contract confirmation, contract payments and activation creation, the row is written in the same transaction as the change, so the change and its row commit together or not at all.
Activation lifecycle
| Action | Meaning |
|---|---|
activation.iccid_validated | An ICCID or LPA code was validated against an operator (public route, no actor) |
activation.created | An activation order was created. Values carry the order id, and the company id for a B2B activation |
activation.identity_initiated | Identity verification was initiated. A status check that is still waiting writes no row |
activation.identity_completed | A verification status check returned completed. Written once per identity attempt, however often the app polls |
activation.identity_rejected | A verification status check returned rejected. Written once per identity attempt, however often the app polls |
activation.identity_expired | A verification status check returned expired. Written once per identity attempt, however often the app polls |
activation.identity_error | A verification status check returned error. Written once per identity attempt, however often the app polls |
Staff and administration
| Action | Meaning |
|---|---|
admin.user.created | An administration user was provisioned; records the role, managedAreas and managedAreasWrite, plus the company id and company role for a company user |
admin.user.updated | An administration user's role, areas, or active state changed; records the old and new role, areas, managedAreasWrite and active state, and is skipped when nothing changed |
admin.user.deleted | An administration user was removed. Old values carry the role, areas, managedAreasWrite and active state, plus the company id and company role for a company user |
admin.user.invited | A sign-in invitation trigger was accepted for an administration user |
admin.plugin.payment.updated | The checkout-wide "accept new payments" setting was updated |
admin.plugin.moyasar.updated | The Moyasar plugin settings were updated |
admin.plugin.paymob.updated | The Paymob plugin settings were updated (key-presence flags only, no keys) |
admin.plugin.email.updated | The email plugin settings were updated |
admin.plugin.mobimatter.updated | The Mobimatter plugin settings were updated |
admin.links_export | The cross-company magic-link oversight CSV was exported |
customer.identity_viewed | A staff member opened a customer's identity check detail |
customer.viewed | A staff member opened a customer's record. Written on entity type customer_record (the customer id), so it stays out of the customer's own history. A repeat view by the same staff member within 10 minutes writes no new row |
customer.searched | A staff member searched customers. Values carry the term's kind, the result count and the portal's search id, never the term. The portal sends a new search id on each submit; a re-run of the same search id by the same staff member within 10 minutes writes no new row, and a search without an id always writes |
officer.completions_searched | An officer searched completions by phone or serial. Values carry the term's kind, the result count and the portal's search id, never the term. The portal sends a new search id on each submit; a re-run of the same search id by the same officer within 10 minutes writes no new row, and a search without an id always writes |
officer.queue_searched | An officer searched the fallout queue by phone, serial or order id. Recorded on activation for the activations queue and esim_store_order for the store queue. Values carry the queue kind, the term's kind, the number of rows on the returned page and the portal's search id, never the term. Only the first page of a search writes a row, and a re-run of the same search id by the same officer within 10 minutes writes none; later pages and the unfiltered queue are not audited |
document.viewed | A staff member downloaded someone else's PDF document through GET /documents/:type/:entityId.pdf. Values carry the document type. A repeat download of the same document by the same staff member within 10 minutes writes no new row |
security.refused | A request was refused by a permission or separation-of-duty rule (staff scope, company admin role lock, refund self-decision, refund opened by a decider, an officer refunding an activation it has not claimed, a non-customer calling a customer payment route). Values carry the error code. Written even when the refused work rolls back |
plugin.email_test_sent | A test email was sent from the email plugin settings. Values carry the masked target address |
officer.delivery_email_resent | An officer re-sent a customer's eSIM delivery email |
officer.operator_checked | An officer asked the operator directly what became of an in-progress activation. Values carry the lookup used, the operator's raw state, and the outcome |
org.settings_updated | The seller-of-record organization settings were updated |
auth.phone_changed | A user changed their phone number |
auth.signed_in | A staff member or company admin signed in. Values carry the method (otp or magic_link). A customer's sign-in writes no row; the sessions table keeps it |
auth.sign_in_refused | A sign-in to a known account was refused. Values carry the method and the reason (deactivated, wrong_client, link_used, link_revoked, link_expired, company_not_approved). A wrong, expired or locked-out code is not recorded, since anyone can send one for any account |
auth.signed_out | A staff member or company admin signed out. A customer's sign-out writes no row |
profile.updated | A user changed their own name or email through PUT /profile or PUT /auth/me. Values carry only the names of the changed fields (fields), never the values |
user.identity_type_changed | A customer's identity type changed. Old and new type in the values, with source set to profile_update or identity_check |
PROFILE_DELETION_REQUESTED | A user requested account deletion |
RETENTION_SWEEP_EXECUTED | The PDPL retention sweep ran. The actor is the staff member who ran it |
BREACH_REPORT_GENERATED | A breach notification report was generated. The actor is the staff member who generated it |
Catalog and store
| Action | Meaning |
|---|---|
package.customization_updated | A package admin override was edited |
store.provider_toggled | A store provider was enabled or disabled |
store.product_customization_updated | A store product override was edited |
store.order_requeued | A stuck store order was re-enqueued for fulfillment |
store.fx_rates_refreshed | The daily FX rate batch was stored |
store.sync_queued | A store catalog sync was queued |
Notifications
| Action | Meaning |
|---|---|
NOTIFICATION_TRIGGERED | A staff member sent a notification (resend, invite, batch) and it was accepted into the pipeline. A system-triggered notification writes no row; its counts are in the notification and delivery rows |
NOTIFICATION_BATCH_CREATED | An administrative notification batch was accepted |
NOTIFICATION_BATCH_CANCELLED | A notification batch was cancelled before execution |
B2B companies
| Action | Meaning |
|---|---|
company.registered | A company was registered |
company.approve | A company was approved. Every status action (approve through unarchive) carries the previous status in the old values, and the new status and the reason in the new values |
company.reject | A company was rejected |
company.suspend | A company was suspended |
company.reactivate | A company was reactivated |
company.archive | A suspended or rejected company was archived |
company.unarchive | An archived company was returned to the status it was archived from |
company.updated | An internal admin edited a company's details. Old and new values carry only the changed fields, with the contact phone and email masked |
company.payment_mode_set | The company's payment mode was chosen or switched. Old values carry the previous mode |
company.user.invited | A portal user was invited. Written on the company with the user id and company role; an invite with no company yet is written on the user |
company.user.deactivated | A company portal user was deactivated. Values carry the user id and company role |
company.user.removed | A company portal user was deleted. Values carry the user id and company role |
company.payment_document_viewed | A staff member downloaded a contract payment document. Values carry the contract and payment ids |
company.roster_stage | Roster rows were staged from a CSV upload. Values carry the counts and the created roster ids |
company.roster_export | The roster CSV was exported |
company.roster_assign | A beneficiary's package assignment was changed or cleared. Old values carry the previous assignment |
company.roster_remove | A beneficiary was removed from the roster |
company.links_send | Activation invite links were minted and delivered. Values carry the counts and the minted roster ids, never the tokens |
company.links_export | Activation invite links were minted for self-distribution. Values carry the counts and the minted roster ids, never the tokens |
company.links_revoke | Open invitations were revoked. Values carry the count and the revoked roster ids |
company.quote_submit | A quote draft was submitted for review |
company.quote_accept | A countered quote version was accepted |
company.quote_decline | A countered quote version was declined |
company.quote_review_start | A submitted quote moved into review |
company.quote_counter | A quote was countered with a new linked version |
company.quote_reject | A quote was rejected |
company.contract_confirm | A quote was accepted and its contract created |
company.payment_record | A contract payment was recorded. Values carry the payment id |
company.billing_export | The billing CSV was exported |