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}/auditRole: 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.
Export
Section titled “Export”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.
Export jobs
Section titled “Export jobs”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}Verifying the chain
Section titled “Verifying the chain”There is no HTTP endpoint for chain verification. Run:
z4j audit verifyThe 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.