Audit log
The audit log is the canonical record of every state-changing action on the BookFlow platform. It is append-only, indexed by actor and target, and is the basis for SOC 2, GDPR data-subject requests, support escalations, and post-incident reviews. Super admins can read it at https://booking.netwit.ca/admin/audit.
What gets logged
Two sources write to the audit log today:
- Super admin actions — every
POST,PATCH, andDELETEagainst/api/admin/*writes one row.actor_type = 'super_admin'. - Tenant actions (limited) — selected tenant-side actions (e.g. login, password change, Stripe Connect onboarding completed) write rows with
actor_type = 'tenant_user'or'customer'. Coverage is partial; the platform backlog expands it as more tenant actions come online.
updated_at column and no soft-delete. If you need to redact PII from a row (e.g. for GDPR Article 17), the procedure is to file a D1 migration that copies the row with the field blanked and adds a gdpr_redacted side-table entry. Do not run DELETE FROM audit_log.Event names
Action names are dot-namespaced strings. The current set:
| action | When it is written | actor_type |
|---|---|---|
| admin.login | Successful POST /api/admin/login | super_admin |
| admin.logout | Successful POST /api/admin/logout | super_admin |
| tenant.suspended | PATCH /api/admin/tenants/:id with isSuspended=true | super_admin |
| tenant.restored | PATCH /api/admin/tenants/:id with isSuspended=false | super_admin |
| tenant.plan_changed | PATCH /api/admin/tenants/:id with plan set | super_admin |
| tenant.updated | PATCH /api/admin/tenants/:id with only suspendedReason set (no isSuspended or plan change) | super_admin |
| admin.tenant.impersonate | Impersonation session opened or page viewed | super_admin |
| admin.user.view | Cross-tenant user lookup (when wired up) | super_admin |
The admin UI also writes rows for actions it takes client-side (e.g. an impersonation page view). The event-name list above is the canonical set for the current build; if you see a new action in the log that is not in this table, check the source of writeAudit() in apps/api/src/routes/admin.ts.
Row schema
From migration 0003:
| Column | Type | What it stores |
|---|---|---|
| id | TEXT PK | 16-byte random hex. Immutable. |
| actor_type | TEXT | 'super_admin' | 'tenant_user' | 'customer' | 'system' |
| actor_id | TEXT | The ID of the actor. For super_admin rows, the super_admins.id. |
| target_type | TEXT | 'tenant' | 'user' | 'booking' | 'plan' etc., or NULL. |
| target_id | TEXT | The ID of the affected object, or NULL. |
| action | TEXT | Dot-namespaced event name. |
| before_value | TEXT (JSON) | JSON of the row before the change, or NULL. |
| after_value | TEXT (JSON) | JSON of the row after the change, or NULL. |
| reason | TEXT | Free-text reason. Super admins are expected to fill this in for PATCH calls. |
| ip | TEXT | Client IP from cf-connecting-ip (preferred) or x-forwarded-for. |
| user_agent | TEXT | User-Agent header verbatim. |
| created_at | TEXT | ISO-8601 UTC, set by SQLite default. |
Filtering
The console has one filter: a free-text search on the action column (a LIKE %term% match, not exact). For example, typing tenant.suspend returns both tenant.suspended and (if any) rows whose action contains that substring.
The API accepts four filter parameters, used in combination:
actionstringLIKE %term% match against the action column. Use this to grab all rows for a given event family (e.g. action=tenant. returns every tenant action).
targetTypestringExact match. Common values: "tenant", "user", "booking", "plan".
targetIdstringExact match against target_id. Combine with targetType to scope to a single tenant, user, or booking.
actorIdstringExact match against actor_id. Pass a super admin's id to see every action that admin took.
pageintegerMin 1, default 1.
limitintegerMin 1, max 200, default 50.
Common queries
Every action against a specific tenant
curl "https://booking-api.netwit.ca/api/admin/audit?targetType=tenant&targetId=$TENANT_ID&limit=200" \
-H "Authorization: Bearer $ADMIN_TOKEN"Every action a specific super admin took this week
curl "https://booking-api.netwit.ca/api/admin/audit?actorId=sa_42&limit=200" \
-H "Authorization: Bearer $ADMIN_TOKEN"Every suspension in the last 30 days
There is no date-range filter in the API. Workaround: pull a large page (limit=200, page through) and filter by created_at client-side. The audit_log table has an index on (action, created_at DESC), so a date-range filter is on the roadmap.
curl "https://booking-api.netwit.ca/api/admin/audit?action=tenant.suspended&limit=200" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '.data[] | select(.createdAt >= "2026-06-26")'Retention
Audit rows are retained for 7 years. The retention job runs monthly and deletes nothing — it just enforces a floor. After 7 years, the job archives rows older than the retention window to R2 (cold storage) and removes them from D1. The archive object is named audit-archive/YYYY-MM/<shard>.jsonl.gz. R2 retention on the archive is also 7 years (per SOC 2 Type II).
audit_log in this guide is the super-admin audit log, and it is always 7 years.What this page does not do
- It does not export to CSV. To export, page through the API and write the rows to a file. The console is read-only.
- It does not show diffs inline. The
before_valueandafter_valueJSON columns are returned by the API but the console currently only rendersaction,actor_type,target_type, andreason. To see a diff, query the API directly. - It does not let you page beyond 200 rows in a single API call. The hard cap is 200.
API
/api/admin/auditAudit log with optional action (LIKE), targetType, targetId, actorId filters. Paginated. Default 50 rows, max 200.
Sample call
curl "https://booking-api.netwit.ca/api/admin/audit?action=tenant.&page=1&limit=50" \
-H "Authorization: Bearer $ADMIN_TOKEN"Response shape
{
"data": [
{
"id": "al_abc123",
"actorType": "super_admin",
"actorId": "sa_42",
"targetType": "tenant",
"targetId": "t_xyz",
"action": "tenant.suspended",
"reason": "Chargeback fraud - investigating",
"ip": "203.0.113.42",
"createdAt": "2026-07-25T18:11:00Z"
}
],
"pagination": { "page": 1, "limit": 50, "total": 1287 }
}Note: before_value and after_value are not returned in the list endpoint — they are only returned when you fetch the tenant detail (the 50 most recent rows for that tenant include the JSON diffs). To see a full diff for an arbitrary row, you must query D1 directly.
Email hello@netwit.ca or call +1-604-206-8169. NetWit responds in 1 business day.