Platform operators

Audit log

Platform operatorsAudit 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, and DELETE against /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.
The audit log is for reads, not writes.
There is no API to delete or edit an audit row. The table has no 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:

actionWhen it is writtenactor_type
admin.loginSuccessful POST /api/admin/loginsuper_admin
admin.logoutSuccessful POST /api/admin/logoutsuper_admin
tenant.suspendedPATCH /api/admin/tenants/:id with isSuspended=truesuper_admin
tenant.restoredPATCH /api/admin/tenants/:id with isSuspended=falsesuper_admin
tenant.plan_changedPATCH /api/admin/tenants/:id with plan setsuper_admin
tenant.updatedPATCH /api/admin/tenants/:id with only suspendedReason set (no isSuspended or plan change)super_admin
admin.tenant.impersonateImpersonation session opened or page viewedsuper_admin
admin.user.viewCross-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:

ColumnTypeWhat it stores
idTEXT PK16-byte random hex. Immutable.
actor_typeTEXT'super_admin' | 'tenant_user' | 'customer' | 'system'
actor_idTEXTThe ID of the actor. For super_admin rows, the super_admins.id.
target_typeTEXT'tenant' | 'user' | 'booking' | 'plan' etc., or NULL.
target_idTEXTThe ID of the affected object, or NULL.
actionTEXTDot-namespaced event name.
before_valueTEXT (JSON)JSON of the row before the change, or NULL.
after_valueTEXT (JSON)JSON of the row after the change, or NULL.
reasonTEXTFree-text reason. Super admins are expected to fill this in for PATCH calls.
ipTEXTClient IP from cf-connecting-ip (preferred) or x-forwarded-for.
user_agentTEXTUser-Agent header verbatim.
created_atTEXTISO-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:

actionstring

LIKE %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).

targetTypestring

Exact match. Common values: "tenant", "user", "booking", "plan".

targetIdstring

Exact match against target_id. Combine with targetType to scope to a single tenant, user, or booking.

actorIdstring

Exact match against actor_id. Pass a super admin's id to see every action that admin took.

pageinteger

Min 1, default 1.

limitinteger

Min 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).

Per-plan retention tier.
The Plans and billing guide lists tenant-visible audit retention as 90 days (Free), 1 year (Pro), 7 years (Enterprise). That retention refers to the tenant-facing audit feed (the AI action log in the dashboard). The platform-wide 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_value and after_value JSON columns are returned by the API but the console currently only renders action, actor_type, target_type, and reason. 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

GET/api/admin/audit

Audit 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.

Need a human?

Email hello@netwit.ca or call +1-604-206-8169. NetWit responds in 1 business day.