Skip to content

Audit trail ​

Every state change a person, an integration or the system makes is appended to the tenant's audit trail (audit_events): who (actorType, actorId), what (action), on which resource (resourceType, resourceId), when (occurredAt), a JSON payload with the details and, for a change made through the API, the request that made it (meta). Rows are append-only and tenant-scoped (row-level security). The API reads them with GET /audit-events and GET /audit-events/:id (audit:read: admins and analysts) and exports them with GET /audit-events/export and on a schedule (audit:export: admins only). The integration role has neither. What a payload shows depends on the reader's permissions: see Payloads by caller.

Events ​

Actions are dotted, lower-case verbs. actorType is one of user (a person; actorId is the Zitadel user id), service (a service user calling the API with a token), applicant (the holder of a signed collection link), system (seeds, migrations, operator commands, the API recording a vendor's webhook, the worker's circuits) or workflow (the worker acting on behalf of a run). The trail's filter also offers app, which no event uses.

Subjects, runs and decisions ​

ActionResourceAppended byPayload
subject.createdsubjectPOST /subjects{ kind, externalId }
subject.updatedsubjectPATCH /subjects/:id{ paths }: the paths that changed (data.address.city, tags, status), never their values
subject.exportedsubject_export (the tenant id)GET /subjects/export{ filter, rows, truncated }
workflow.run.startedworkflow_runPOST /workflow-runs and POST /workflow-runs/:id/replay (workflow-engine client){ definitionKey, definitionVersion, subjectId, temporalWorkflowId, temporalRunId, taskQueue, trigger, correlationId, replayOf } (the last two when set)
workflow.run.cancelledworkflow_runPOST /workflow-runs/:id/cancel, or the worker when the run's form was abandoned{ reason, closedCaseIds, withdrawnSubmissionIds }
workflow.run.completedworkflow_runthe worker, when a run finishes{ definitionKey, definitionVersion, subjectId, decisionId }
workflow.run.failedworkflow_runthe worker, when a run fails{ definitionKey, definitionVersion, subjectId, decisionId, error }
workflow.rules.evaluatedworkflow_runan evaluate_rules step{ ruleKeys, ruleSetKey, ruleSetVersion, outcome, riskScore, results }
decision.emitteddecisionan emit_decision step{ outcome, riskScore, source, subjectId, workflowRunId, caseId, ruleSetKey, ruleSetVersion }
events.ingestedeventsPOST /events{ accepted, duplicates }
backtest.startedbacktestPOST /backtests{ ruleKey, ruleVersion, compareWithRuleSetKey } or { workflowKey, workflowVersion }, with { sample, temporalWorkflowId, taskQueue }

results holds one rule result per rule (ruleId, ruleKey, ruleVersion, severity, weight, outcome, score, reasons, details, durationMs, shadow). Its details are what the rule compared (a list lookup's value, a velocity rule's group key, a comparison's actual value) or an app rule's whole output.

Collection flows and documents ​

ActionResourceAppended byPayload
collection.submission.createdcollection_submissionPOST /collection-submissions, a wait_for_collection step{ flowKey, flowVersion, subjectId, workflowRunId, hasLink }, plus linkTtlSeconds from the API
collection.submission.submittedcollection_submissionPOST /collection-submissions/:id/submit{ flowKey, flowVersion, subjectId, workflowRunId }
collection.submission.link_issuedcollection_submissionPOST /collection-submissions/:id/link{ subjectId, expiresAt }
collection.submission.link_refreshedcollection_submissionPOST /collection-submissions/:id/refresh (the applicant, keeping a live link alive){ expiresAt }
collection.submission.link_requestedcollection_submissionPOST /collection-submissions/:id/resume-link (the applicant, asking for a new link); raises submission.link_requested{ subjectId, flowKey, workflowRunId, caseId }
collection.submission.handoff_startedcollection_submissionGET /collection-submissions/:id/handoff (the applicant, continuing a vendor check in its SDK){ workflowRunId, callbackId, appName, sdk }, never the token
collection.submission.withdrawncollection_submissionPOST /collection-submissions/:id/withdraw, deciding or closing a case with an open request, cancelling its run{ caseId, reason, workflowRunId } (reason: withdrawn, case_decided or run_cancelled)
collection.submission.expiredcollection_submissionthe run, when a request for information expires unanswered; the worker, for a form nobody saved for the tenant's collection.abandonAfterDays{ caseId, subjectId, workflowRunId }, plus reason: 'abandoned' and abandonAfterDays from the worker
document.createddocumentPOST /documents (upload URL issued), or a request for information copying an answered file{ fileName, contentType, sizeBytes, subjectId, submissionId, fieldKey }; a copy has { fieldKey, fileName, sizeBytes, subjectId, copiedFrom }
document.uploadeddocumentupload finalised{ sha256, contentType, sizeBytes }
document.rejecteddocumentfinalisation or the scan refused the file{ reason, code }, plus declaredContentType at finalisation
document.deleteddocumentDELETE /documents/:id (the applicant, while the form is open){ fileName, submissionId, fieldKey, status }
document.scanneddocumentthe malware scan finished{ engine, verdict, reencoded }, or { engine, verdict, signature } when infected
document.downloadeddocumentGET /documents/:id/download{ fileName, expiresAt }

The signed collection link is never audited: hasLink records that one was issued. The follow-up of a request for information records its caseId and the submission it was copied from (followUpOf) on collection.submission.created.

Cases ​

ActionResourceAppended byPayload
case.createdcasea create_case step{ type, priority, subjectId, workflowRunId, dueAt }
case.assigned / case.unassignedcaseassign, claim, unassign{ assigneeId, previousAssigneeId, status }
case.decidedcasePOST /cases/:id/decide, or the run when a request for information expires{ decisionId, outcome, reasons, subjectId, workflowRunId }
case.info_requestedcasePOST /cases/:id/request-info{ submissionId, message, expiresAt, workflowRunId }
case.info_received / case.info_withdrawncasethe run, once the applicant answered or the request was withdrawn{ caseId, submissionId, workflowRunId, dueAt }
case.closedcasePOST /cases/:id/close, the run closing it, or cancelling its run{ reason, decisionId, workflowRunId }, or { caseId, reason, workflowRunId } from the run
reason_codes.updatedreason_codes (the tenant id)PUT /reason-codes{ codes }: the catalogue's codes in order
case.sla.breachedcasethe SLA timer of the run{ caseId, priority, workflowRunId }
case.note.added / case.note.edited / case.note.deletedcasecase notes{ noteId, length } when added, { noteId, authorId } otherwise

Definitions and governance ​

ActionResourceAppended byPayload
<kind>.version.created<kind>PUT <prefix>/:key{ key, version, note }
<kind>.version.updated<kind>PATCH <prefix>/:key/versions/:version{ key, version, note } (every save in place, autosaves too)
collection_flow.previewedcollection_flowPOST /collection-flows/:key/versions/:v/preview{ key, version, expiresAt }
<kind>.published<kind>POST <prefix>/:key/publish, or an approval{ key, version }
definition.approval.requesteddefinition_approvalPOST .../versions/:v/request-approval{ kind, key, version, comment }
definition.approval.approveddefinition_approvalPOST .../versions/:v/approve{ kind, key, version, comment }
definition.approval.rejecteddefinition_approvalPOST .../versions/:v/reject{ kind, key, version, comment }
definition.approval.withdrawndefinition_approvalPOST .../versions/:v/withdraw{ kind, key, version, comment }
definition.approval.commenteddefinition_approvalPOST /approvals/:id/comments{ kind, key, version, commentId, comment }
rule_definition.dry_runrule_definitionPOST /rule-definitions/:key/dry-run, per version{ key, version, outcome }
definitions.importedpolicy_packPOST /definitions/import{ name, version, counts, keys, publish, approvalRequired }
definitions.exportedpolicy_packGET /definitions/export{ name, version, counts, keys }

<kind> is workflow_definition, rule_definition, rule_set or collection_flow. The publish that an approval performs adds approvalId to its payload. A policy pack import also appends the per-item events above (<kind>.version.created with the pack name as pack, then <kind>.published, also with pack, or definition.approval.requested with publish=true, and the list events); the pack events themselves use the tenant id as the resource id.

Lists, apps, webhooks, service users, tenant and the audit trail itself ​

ActionResourceAppended byPayload
list.created / list.updatedlistPUT /lists/:key{ key, kind, caseInsensitive }
list.deletedlistDELETE /lists/:key{ key, entryCount }
list.entries.addedlistlist entry routes{ key, added, skipped }
list.entries.removedlistlist entry routes{ key, removed, requested }
list.importedlistPOST /lists/:key/import (CSV){ key, added, skipped, invalid }
app.callback.receivedapp_callbackvendor webhooks (POST /webhooks/apps/…){ appName, externalId, status, eventId } (never the payload)
app.uploadedapp_versionPOST /apps/uploads{ name, version, sha256, sizeBytes }
app.publishedapp_versionPOST /apps/:name/versions/:id/publish{ name, version, sha256 } (a direct publish, no approval required)
app.approval.requestedapp_versionPOST /apps/:name/versions/:id/publish{ name, version, approvalId } (when the tenant requires approvals for apps)
app.approval.decidedapp_versionPOST .../versions/:id/approve / reject{ name, version, approvalId, decision }
app.withdrawnapp_versionDELETE /apps/:name/versions/:id{ name, version, sha256 }
app.installed / app.configuredapp_installPUT /apps/:name/install{ name, versionId, version, enabled, configKeys, missingSecrets } (the config keys, not the values)
app.uninstalledapp_installDELETE /apps/:name/install{ name, versionId, failedCallbacks, secretsRemoved }
app.testedapp_installPOST /apps/:name/test{ name, action, status, durationMs } (no input or output)
app.secret.storedapp_installPOST /apps/:name/secrets/:secret{ name, secret } (never the value)
app.secret.revokedapp_installDELETE /apps/:name/secrets/:secret{ name, secret }
app.circuit.opened / app.circuit.closedapp_installthe worker's circuit breaker{ name, failures }
webhook_endpoint.createdwebhook_endpointPOST /webhook-endpoints{ url, events, enabled } (never the secret)
webhook_endpoint.updatedwebhook_endpointPUT /webhook-endpoints/:id{ changed, url, events, enabled }
webhook_endpoint.deletedwebhook_endpointDELETE /webhook-endpoints/:id{ url }
webhook_endpoint.testedwebhook_endpointPOST /webhook-endpoints/:id/test{ delivered, responseCode, latencyMs }
webhook_endpoint.secret.rotatedwebhook_endpointPOST .../rotate-secret{ previousSecretExpiresAt }
webhook_delivery.retriedwebhook_deliveryPOST /webhook-deliveries/:id/retry{ endpointId, event, status, attempts }
service_user.createdservice_userPOST /service-users{ name, role, purpose }
service_user.updatedservice_userPATCH /service-users/:id{ name } and what changed: purpose, enabled
service_user.key.createdservice_userPOST /service-users/:id/keys{ keyId, expiresAt } (never the token)
service_user.key.revokedservice_userDELETE /service-users/:id/keys/:keyId{ keyId }
tenant.settings.updatedtenantPUT /tenants/me/settings{ changed }: the dotted paths that changed, never the values
tenant.suspended / tenant.resumedtenantPOST /tenants/me/suspend / resume{ reason } / {}
tenant.links.revokedtenantPOST /tenants/me/revoke-links{ validAfter, revokedLinks }
team.user.invitedteam_memberPOST /team/invitations{ email, role }
team.user.role_changedteam_memberPUT /team/users/:id/role{ from, to }
team.user.deactivated / .reactivatedteam_memberPOST /team/users/:id/(de|re)activate{}
team.invitation.resent / .cancelledteam_memberthe invitation routes{}
audit.exportedaudit_exportthe export and scheduled exports{ format, filter, rows, sha256 }; scheduled: scheduleId, runId, fileName
audit.export.downloadedaudit_export_runPOST /audit-export-runs/:id/download{ scheduleId, fileName, rows }
audit_export_schedule.created / .updatedaudit_export_schedulethe schedule routes{ name, cron, filter, format, enabled }
audit_export_schedule.deletedaudit_export_scheduleDELETE /audit-export-schedules/:id{ name }

List entry values are never audited, only their counts; secret values, webhook signing secrets and key tokens never are.

Request metadata ​

Every row written while the API serves a request carries meta: { requestId, route, ip, userAgent }, the request id the response's x-request-id header names, the method and route pattern (POST /cases/:id/decide), the client's address and its user agent (300 characters at most). The auth hook puts them on the request's tenant context and the audit writer stores them with the row, so every route records them without doing anything. Rows the worker writes (workflow steps, circuits, scheduled exports) have no meta. The IP and user agent are personal data: callers without subjects:pii read them as null.

Payloads by caller ​

GET /audit-events, GET /audit-events/:id, the export and the audit entries of GET /subjects/:id/timeline pass every payload through presentAuditEvent (apps/api/src/routes/redact.ts), the rule the entity routes follow (see Admin console) applied by key, since a payload has no fixed shape. Stored rows are never changed. Admins hold runs:context and subjects:pii and read every payload as stored; analysts hold neither; the integration role cannot read the trail.

ClassFieldsWithout the permission
Identifiers, statuses, flags and countssubjectId, workflowRunId, caseId, decisionId, noteId, approvalId, commentId, assigneeId, externalId, eventId, outcome, riskScore, priority, status, verdict, hasLink, contentType, sizeBytes, accepted, added, rows, sha256, timestampsas stored
Definition keys, versions and settingskey, version, kind, ruleKeys, ruleSetKey, flowKey, definitionKey, workflowKey, pack, keys, counts, sample, configKeys and missingSecrets (names), embedOriginsas stored
Run context, app input and output, rule details, submission dataevery value under a context, data, details, input or output key, at any depth: today the details of each workflow.rules.evaluated result"[redacted]" without runs:context, keys and array lengths kept
Personal datavalues under a personal-data key anywhere else (emails, phone numbers, identity and account numbers, dates of birth, street lines)masked without subjects:pii
Free textrule result reasons, the reasons messages of case.decided (a reviewer's note is manual.note), approval comment, the reason of case.closed and document.rejected, fileName, signatureas stored

Free text is returned as stored, so it can still show what the redaction hides: a list lookup's reason quotes the value it found on the list, and a reviewer's note is whatever they typed. No writer stores a signed collection link or a secret value.

Filters ​

GET /audit-events returns events newest first (occurredAt desc, id desc) and accepts:

ParameterMeaning
actionprefix match: case. is every case event, case.note.added that one action
actorId, actorTypeexact match (actorType: one of the six types above)
resourceType, resourceIdexact match; together they give one resource's history
from, toISO timestamps, both inclusive
limit, offsetpaging (the repository clamps limit)

The response is { items, total, totalIsEstimate }: total counts the events matching the filters, ignoring limit and offset (one count(*) with the same conditions), so a reader can page with "of n" and know an export's row count before downloading it. The count stops at 100,000 rows: past that total is 100,000 and totalIsEstimate is true ("100,000+" in the console), so a page over the whole log costs at most that many index entries. The export takes the same filters (without paging) plus format.

GET /audit-events/:id (audit:read) returns one event with two additions, and answers 404 when the event does not exist or belongs to another tenant. It takes the list's filters: neighbors.newerId and neighbors.olderId are the events listed just above and below it under them (the console's ↑ and ↓ work across pages). related names the case, decision, run and subject the event concerns: from its resource, the ids its payload holds and what those records point at (a case's run and subject, a decision's case). Its payload is presented like the list's (see Payloads by caller).

GET /audit-events/count (audit:read) takes the same filters and answers { total, totalIsEstimate, approxBytes: { csv, jsonl } }, an export's row count and size estimated from the newest 200 matching events encoded as the export encodes them.

Saved views ​

GET /audit-views, POST /audit-views ({ name, filters }, the filter row as typed; a view of the same name is replaced) and DELETE /audit-views/:id (audit:read) keep a user's named filter sets, at most 50 each. They are per user and not audited.

Exports ​

GET /audit-events/export?format=csv|jsonl&<filters> streams every matching event, oldest first, without loading them into memory (keyset pages of 1000). The response is an attachment named audit-<from>-<to>.<ext> (UTC timestamps like 20261002T101500Z; start when there is no from, the request time when there is no to), or <filename>.<ext> when filename (letters, digits, ., _ and -, 100 at most) names it.

  • CSV (text/csv, RFC 4180, CRLF line ends): a header occurredAt,id,actorType,actorId,action,resourceType,resourceId,payload,meta, then one row per event. payload and meta are JSON strings (meta empty for rows the worker wrote). Fields holding a comma, a quote or a line break are quoted and inner quotes doubled.
  • JSON Lines (application/x-ndjson): one object per event with the same keys, payload and meta as JSON.

Payloads are presented for the caller as on the list. Only admins hold audit:export, and they hold runs:context and subjects:pii, so an export carries every payload as stored; a role granted audit:export without those permissions would export what it may read, and the digests below cover those bytes.

Digests and the manifest line ​

The last line of every export is a manifest:

  • CSV: # sha256=<hex> rows=<n>
  • JSONL: {"_manifest":{"sha256":"<hex>","rows":<n>}}

sha256 is the SHA-256 of every byte before the manifest line (header included) and rows the number of events. A file whose last line is not a manifest was cut short. In addition the response declares Trailer: X-Export-Sha256 and sends the SHA-256 of the whole body, manifest included, as an HTTP trailer, and X-Export-Rows with the number of events; trailers are often dropped by proxies and by curl, which is why the manifest exists.

Verify a download:

bash
curl -sS -H "authorization: Bearer $TOKEN" \
  "$API_URL/audit-events/export?format=csv&from=2026-10-01T00:00:00Z" -o audit.csv

tail -n 1 audit.csv                                    # # sha256=<hex> rows=<n>
LC_ALL=C sed '$d' audit.csv | shasum -a 256            # must equal <hex>
# Linux: head -n -1 audit.csv | sha256sum
echo $(( $(LC_ALL=C sed '$d' audit.csv | wc -l) - 1 )) # data rows (minus the header) = <n>

For JSONL the row count is sed '$d' audit.jsonl | wc -l (no header), and the manifest is read with tail -n 1 audit.jsonl | jq ._manifest.

Each completed export is itself audited as audit.exported with { format, filter, rows, sha256 } (the manifest digest). That row is appended after the last byte of the export, so it is never part of it. An export the client abandons midway is not audited.

Scheduled exports ​

GET, POST /audit-export-schedules and GET, PUT, DELETE /audit-export-schedules/:id (audit:export) manage exports that run on a schedule: { name, cron, filter, format, enabled }, with cron five fields in UTC and filter the list's filters without from and to. Each schedule is a Temporal schedule (audit-export:<tenant>:<schedule>, one run at a time, paused while disabled) that starts the auditExportSchedule workflow on the backtest queue. A run exports the events from where the last successful run's window ended (the first run: from the schedule's creation) to ten seconds before it started, a half-open window, so consecutive runs hold every event once. The file is encoded as the export route encodes it, manifest line included, with payloads as stored, and written to the deployment's object storage as audit-exports/<tenant>/<schedule>/<name>-<from>-<to>.<ext>; a run is audited as audit.exported with the window, scheduleId, runId and fileName.

GET /audit-export-schedules/:id/runs lists the newest 20 runs (window, status, rows, bytes, digest, error) and POST /audit-export-runs/:id/download answers a five-minute link to a run's file, audited as audit.export.downloaded. A failed run (storage refused, a file over 256 MiB) is recorded with its error, shows on Home's attention list for a day, and leaves its window to the next run. Schedules need object storage and Temporal (503 otherwise), and a tenant has ten at most. Changes are audited as audit_export_schedule.created, .updated and .deleted; deleting a schedule keeps the files already written.

Deferred ​

  • Integrity chain. A per-tenant hash chain over audit rows (each row carrying the hash of the previous one), so tampering with stored rows is detectable, is deferred. Exports carry digests now, which proves a file is complete and unchanged since it was downloaded, not that the rows were never edited in the database.
  • Retention. Audit rows are kept indefinitely; per-tenant retention policies (and exporting before purge) are deferred.
  • Scheduled export destinations. Scheduled exports go to the deployment's object storage, in UTC, at most 256 MiB a file; a tenant's own bucket, larger files and tenant time zones are deferred.

All three are recorded in docs/plans/deferred.md.

Released under the Apache-2.0 License.