Skip to content

Audit log

Every privileged action recorded in the platform writes a row to audit_log. The table is append-only at the database level (a trigger refuses UPDATE and DELETE), so the audit trail is tamper-evident even to operators with database access.

ActionWhen
workspace.createPOST /workspaces
workspace.updatePATCH /workspaces/:slug
site.createPOST /workspaces/:slug/sites
site.deleteDELETE /workspaces/:slug/sites/:id
key.createPOST /workspaces/:slug/keys
key.revokeDELETE /workspaces/:slug/keys/:keyId
dlq.replayPOST /sites/:siteId/dlq/:eventId/replay
gdpr.exportPOST /sites/:siteId/gdpr/export
gdpr.deletePOST /sites/:siteId/gdpr/delete

Read-only analytics queries are NOT audited — auditing every GET /sites/:siteId/pageviews would push the audit table to the same volume as the events table itself. Privileged mutations and GDPR access are the v1.0 audit set.

GET /workspaces/:slug/audit?limit=&cursor=
Authorization: Bearer <admin-token-or-admin-api-key>
ParamDefaultRange
limit1001–1000
cursorNOWISO-8601 timestamp; rows with ts < cursor are returned

Response (200):

{
"entries": [
{
"id": "<uuid>",
"workspace_id": "<uuid>",
"actor_kind": "admin" | "apiKey" | "system",
"actor_id": "<uuid>" | null,
"action": "site.create",
"target": "site_marketing" | null,
"metadata": { /* free-form JSONB */ },
"ts": "2026-05-02T14:00:00.000Z"
}
],
"next_cursor": "<iso>" | null
}

Pagination is cursor-based with descending timestamp — the most recent entry is page 1.

The audit_log table has a BEFORE UPDATE and BEFORE DELETE trigger that raises an error. Even a superuser cannot mutate rows through normal DML. This is by design — the audit log is a compliance artifact, not a mutable table.

If an operator needs to purge old rows for storage reasons, they must either disable the trigger (which is an audited action in itself, when the platform expands the audit set), or rotate to cold storage via COPY audit_log TO ….

The platform records subject ids in audit entries for GDPR actions, but SHA-256-truncates them to 12 hex characters before they land in the row. This prevents the audit log from being its own GDPR violation while still giving operators enough fingerprint to correlate audit entries with external incident data.

Every privileged endpoint emits its audit row inside the same database transaction as the underlying mutation. There is no separate “fire and forget” audit pipeline — if the mutation commits, the audit row is on disk; if the mutation fails, the audit row is rolled back.

The actor_kind enum (admin / apiKey / system) is part of the contract. The action enum is closed-set for v1.x — every value listed in API_CONTRACT §3.4 is guaranteed. New action values may be added (per the versioning policy), existing ones won’t change meaning.

The free-form metadata JSONB is operator-facing and may evolve per action; operators should treat unknown keys as expected-future-fields and not break on them.