Skip to content

Audit API

The dedicated audit endpoint is project-scoped and open to the auditor and admin roles only; a viewer or an operator gets 403. A separate GET /api/v1/activity feed aggregates the same records across every project the caller holds one of those two roles on (a viewer or operator membership admits nothing); it omits user_agent, and only a global admin receives source_ip. A single audit endpoint serves both pagination and export; there is no separate /export route. Chain verification is a CLI command (z4j audit verify), not an HTTP endpoint.

GET /api/v1/projects/{slug}/audit

Role: auditor or admin. The same rule covers the export form below. An API key reaches the endpoint with the audit:read scope; the key's owner still needs one of those roles on the project.

Query params:

Param Type Notes
action_prefix string Prefix match on action, e.g. auth. or command.issue.. At most 80 characters (422 beyond).
outcome string allow, deny or error; a few writers record failure. At most 20 characters.
user_id UUID Filter by actor user.
since RFC 3339 Lower bound on occurred_at.
cursor opaque Pagination cursor from prior page.
limit int Request validation accepts 1..5000; the paginated path then clamps it to Z4J_REST_MAX_PAGE_SIZE (default 500) and uses 50 when omitted.
format string csv, json, or xlsx. Switches to export mode (see below).
fields string Comma-separated column projection. Only honoured in export mode. Unknown column names are silently ignored. At most 400 characters.

Response (paginated mode):

{
"items": [
{
"id": "01H...",
"occurred_at": "...",
"user_id": "...",
"project_id": "...",
"action": "command.issue.retry_task",
"target_type": "task",
"target_id": "01H...",
"result": "success",
"outcome": "allow",
"event_id": null,
"source_ip": "203.0.113.10",
"user_agent": "Mozilla/5.0 ...",
"metadata": {"original_task_id": "01H...", "new_task_id": "01H..."}
}
],
"next_cursor": "..."
}

row_hmac and prev_row_hmac are not part of this payload. They are chain machinery, not audit content, and the API does not hand them out over HTTP.

Set format=csv (or json / xlsx) on the same endpoint to switch to export mode. Pagination is ignored. CSV and JSON fail loudly if the filter exceeds 50,000 rows; XLSX has a lower 25,000-row in-memory cap and also fails loudly. The failure is 422 validation_error with details.cap and details.format. Narrow by action_prefix, outcome, or since and re-run.

A served export is itself on the record: it writes one audit.export row through the chained writer, naming the format, the filters, the selected columns and the row count, with the caller, key and address on the usual columns. Page reads of the list write nothing; a refused export writes nothing.

GET /api/v1/projects/{slug}/audit?format=csv&since=2026-01-01T00:00:00Z&action_prefix=auth.

Exports carry the audit columns (id, occurred_at, action, target_type, target_id, result, outcome, user_id, event_id, source_ip, user_agent, metadata), and fields selects a subset of them. They do not carry row_hmac or prev_row_hmac, so an export is a record to ship to a SIEM or hand to an auditor, not something a downstream system can re-verify the chain from. Verification runs against the database, below.

Above the caps, export in the background. A job takes the same filters as the synchronous export, is written page by page to the configured export sink by the brain's export-jobs worker, and can be any size for CSV and JSON; XLSX is bounded by the worksheet format at 1,048,575 rows. For the same filter a job writes the same bytes the synchronous download would have returned. Jobs need an export sink (Z4J_EXPORT_SINK=local or s3); without one the create route answers 409. Sinks, settings and the scheduled chain-head export are on the audit exports page.

Role: auditor or admin on the project, the same as the synchronous export (Action.EXPORT_AUDIT). For API keys every route in this section requires audit:read, including the create route: queueing an export reveals exactly what the synchronous download reveals and changes nothing else.

Route Purpose
POST /api/v1/projects/{slug}/audit/export-jobs Queue a job. Body: format (csv, json or xlsx), optional action_prefix, outcome, user_id, since, and fields (a list of column names; an unknown name is 422). Answers 202 with the job and a Location header. 409 when no sink is configured.
GET /api/v1/projects/{slug}/audit/export-jobs Newest jobs first (limit 1 to 200, default 50), plus sink and sink_location for the sink in effect.
GET /api/v1/projects/{slug}/audit/export-jobs/{job_id} One job: status, progress, location, size, or the failure reason.
GET /api/v1/projects/{slug}/audit/export-jobs/{job_id}/download Stream a finished job's file as an attachment. Local sink only: 409 for an S3 job (fetch the object at location with your own credentials) and while the job is not done.

A job is queued when created, running once a worker claims it (with row_count advancing as pages are written), then done (location names the object and size_bytes its length) or failed (error says why). The location is a filesystem path for the local sink or an s3://bucket/key URL for S3; responses never carry a credential. Creating, completing and failing a job each write an audit row (audit.export_job.created, audit.export_job.completed, audit.export_job.failed), so the export is itself in the trail it exports.

{
"id": "4c1f...",
"project_id": "...",
"user_id": "...",
"export_type": "audit",
"format": "csv",
"filters": {"action_prefix": "auth.", "since": "2026-01-01T00:00:00+00:00"},
"status": "done",
"row_count": 184211,
"size_bytes": 61337802,
"sink": "local",
"location": "/var/lib/z4j/exports/audit/default/20260301T020000Z-4c1f....csv",
"error": null,
"created_at": "...",
"started_at": "...",
"completed_at": "...",
"downloadable": true
}

There is no HTTP endpoint for chain verification. Run:

Terminal window
z4j audit verify

The CLI walks the log and reports row counts for the active and frozen generations. It counts every finding, but caps rendered detail at the first 100 plus an overflow marker; use the MISMATCHES (N) header for the total. It exits 0 for a clean chain, 1 for an integrity finding or for a verifier/database failure after settings loaded, and 2 only for settings-load failure or an invalid --limit. A cron job can page on non-zero, but exit 1 alone does not distinguish tampering from a run that could not complete.

Pass --known-head with an envelope you exported earlier to also assess whether the current chain still contains that head. The result is one of CURRENT_MATCH, PRUNE_MATCH, CURRENT_PRUNE_MATCH, VERIFIED_ANCESTOR, INVALID, or UNPROVABLE, and it is reported on its own line. A log rolled back past your exported head reports UNPROVABLE, which is a finding even when every retained row verifies.

For a scheduled check, either wrap the CLI in cron or a Kubernetes CronJob, or enable the built-in verifier worker with Z4J_AUDIT_CHAIN_VERIFY_ENABLED=true (off by default, daily by default, leader-gated). See monitoring for the metrics it emits and HMAC audit chain for the chain construction and its limits.