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), app (an install: its vendor's webhook relayed by the API, its circuit opened or closed by the worker; actorId is the install's name), system (seeds and migrations), workflow (the worker acting on behalf of a run) or operator (the deployment's operator acting through pnpm ops; actorId is the name they gave with --by, which nothing checks).

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, failedCallbackIds } (the app sessions that waited for a webhook, failed)
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.handoff_startedcollection_submissionGET /collection-submissions/:id/handoff (the applicant, sent to the vendor's hosted page for the session their run waits for){ workflowRunId, callbackId, installName, appName, host }: the page's host (with its port when not the default), never its URL
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.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, installs, publish, approvalRequired }; installs is [{ name, app }], one per install the pack declares
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/app-installs/…){ installName, appName, externalId, status, eventId } (never the payload)
app.uploadedapp_versionPOST /apps/uploads{ name, version, sha256, sizeBytes }
app.provenance.recordedapp_versionPUT /apps/:name/versions/:id/provenance{ name, version, kind, signer, source, transparencyLogIndex, trusted, trustedSigner }, trust as the policy judged it when it was recorded
app.publishedapp_versionPOST /apps/:name/versions/:id/publish{ name, version, sha256 } (a direct publish: no approval or review required)
app.publishedapp_versionthe operator's approval of a review (pnpm ops){ name, version, sha256, via: 'review' }, actor operator
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.review.requestedapp_versionPOST .../versions/:id/publish / approve{ name, version, reviewId }, plus approvalId after an approval (when the tenant is under review)
app.review.decidedapp_versionpnpm ops: a review decided, or reviews off{ name, version, reviewId, decision, comment }, actor operator; raises the webhook event of that name
app.withdrawnapp_versionDELETE /apps/:name/versions/:id{ name, version, sha256 }, plus approvalId and reviewId for the request and the review it closed
app.withdrawnapp_versionthe operator's withdrawal (pnpm ops){ name, version, sha256, reason, via: 'operator' }, actor operator
app.module.blockedapp_installpnpm ops apps block, per install it reaches{ name, appName, versionId, version, sha256, scope, reason }, actor operator; raises the webhook event
app.module.unblockedapp_installpnpm ops apps unblock, per install it reachedas app.module.blocked, with the operator's note instead of the reason
app.installedapp_installPOST /app-installs{ name, appName, versionId, version, enabled, autoUpgrade, configKeys, hosts, hostsAdded, hostsRemoved, missingSecrets, readsDocuments, receivesWebhooks }
app.configuredapp_installPUT /app-installs/:installas app.installed, plus superseded (the id of the change it withdrew)
app.install.change.requestedapp_installPOST or PUT /app-installs…, answered 202as app.installed (the state the save requests) plus changeId, newInstall, origin, needsAdded, handlesAdded, superseded and switchedOff
app.install.change.requestedapp_installthe API's automatic upgrades, per held moveas a save's, origin: 'auto_upgrade', plus fromVersionId, fromVersion and rollback, actor system
app.install.change.decidedapp_installPOST …/changes/:id/{approve,reject,withdraw}{ name, appName, changeId, decision, newInstall, origin, versionId, hosts, hostsAdded, needsAdded, handlesAdded } and the outcome (below)
app.install.upgradedapp_installthe API's automatic upgrades, per move applied{ name, appName, fromVersionId, fromVersion, toVersionId, toVersion, automatic: true, rollback, hostsRemoved }, actor system; raises the webhook event
app.install.upgradedapp_installapproving an automatic upgrade's changeas above with automatic: false and the changeId; raises the webhook event
app.install.documents.enabled / .disabledapp_installa save, an approved change or an automatic move{ name, appName, versionId, version, documentsFromConfig, hosts }: the install's calls read the tenant's documents from now on, or no longer do; the actor is who saved, who approved, or system
app.install.renamedapp_installPOST /app-installs/:install/rename{ from, to, appName, aliasKept, aliasReclaimed, rewrite, definitions, skipped }
app.install.alias.removedapp_installDELETE /app-installs/:install/aliases/:alias{ name, alias, appName, force }, plus definitions and runs when it was still in use
app.uninstalledapp_installDELETE /app-installs/:install{ name, appName, versionId, failedCallbacks, secretsRemoved }
app.testedapp_installPOST /app-installs/:install/test{ name, appName, action, status, durationMs } (no input or output)
app.secret.storedapp_installPOST /app-installs/:install/secrets/:secret{ name, appName, secret } (never the value)
app.secret.revokedapp_installDELETE /app-installs/:install/secrets/:secret{ name, appName, secret }
app.circuit.opened / app.circuit.closedapp_installa call that moved the install's circuit (the worker){ install, app, failures }; opened raises the webhook event
app.circuit.configuredapp_installPUT /app-installs/:install/circuit{ install, app, previous, settings }, each { failureThreshold, openSeconds } (null for the deployment's)
app.circuit.resetapp_installPOST /app-installs/:install/circuit/reset{ install, app, state, failures }: what the reset cleared
app.limits.updatedapp_installPUT /app-installs/:install/limits{ name, appName, currency, prices, budgets, previous }, previous null for the first limits
app.cache.clearedapp_installDELETE /app-installs/:install/cache{ install, app, reason: 'manual', entries }: the live cached values it removed, whatever they were (never a value)
app.cache.clearedapp_installa secret stored or revoked, a config change or a move to a version{ install, app, reason, entries }, reason secret, config or version, only when a live value went; the actor is who saved, approved or stored, or system for an automatic upgrade
app.quota.exceededapp_installa call a quota or a budget refused (a run's, or a test call){ install, app, scope, metric, period, max, used, currency, periodStart, resetsAt }, once per limit and period; raises the webhook event
webhook_endpoint.createdwebhook_endpointPOST /webhook-endpoints{ url, events, enabled }: the URL masked (below), never the secret
webhook_endpoint.updatedwebhook_endpointPUT /webhook-endpoints/:id{ changed, url, events, enabled }, the URL masked
webhook_endpoint.deletedwebhook_endpointDELETE /webhook-endpoints/:id{ url }, masked
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 }
tenant.app_review.settenantpnpm ops: the tenant's review turned on or off{ appReview }, actor operator
tenant.app_quota.set / .removedtenantpnpm ops tenants quota: one of the tenant's quotas set or removed{ metric, period, max }, actor operator
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 }

An app_install event's resource id is the install's id; its payload names the install (name, or install for a circuit, a cache or a quota) and the catalogue app it runs (appName, or app). An install created again under a removed one's name has a new id, so the trail keeps the two apart, and a renamed install keeps its id, so its events before and after app.install.renamed read as one install's under two names. A rename's definitions are the versions its rewrite wrote ({ kind, key, version, status }), skipped those it left for a newer draft, and aliasReclaimed the former name the install took back, when it did.

A save records the config's keys, never its values, and the hosts the install calls with it (hosts), those it adds and approved (hostsAdded) and those it drops (hostsRemoved), whether it applied (app.installed, app.configured) or waits for another administrator (app.install.change.requested, which also raises the webhook event of that name). secretPlaces lists each secret variable a step may use with its places ([{ secret: 'apiKey', places: ['path'] }], from the variables' stepPlaces), names and places only, and is absent when no variable grants any. superseded names the change a newer save withdrew, and switchedOff marks a waiting save that turned the install off at once. app.install.change.decided records the decision on a waiting change: an approval adds whether the install is enabled and its missingSecrets, and a rejected or withdrawn new install adds installRemoved and secretsRemoved. A save records autoUpgrade, whether the install upgrades automatically after it, and whether calls through the state it asks for would read the tenant's documents (readsDocuments) and receive its vendor's webhooks (receivesWebhooks).

app.install.documents.enabled and .disabled record the install's documents switch, beside the event of the write that turned it: a save (a new install read none before, and one that waits for its first approval is stored with its config), an approved change, or an automatic move (as system). They name the version the install runs after it, the config field that holds the switch (documentsFromConfig, absent for a version that reads documents on every call) and the hosts the documents may go to. Documents never hold a save for an approval. A save of an install that waits leaves it serving as it was, so a switch it turns on is recorded when its change is approved; one that turns documents off (its config, or a version that reads none) turns them off at once in the config the install serves with, recorded with the save.

The platform's moves of an install that upgrades automatically are the system's: a move that adds nothing is app.install.upgraded (automatic: true, rollback when it moved down off a withdrawn or blocked version), and one that adds hosts or capabilities is a change requested with origin: 'auto_upgrade', naming what it adds (hostsAdded, needsAdded such as documents, handlesAdded such as webhook). Approving that change records app.install.upgraded too, with automatic: false, beside the decision. fromVersionId is the version a save may move the install back to; superseded names an earlier automatic change a newer target withdrew.

A version's review and its blocks are the operator's. A tenant under review (appReviewrequired on GET /tenants/me) records app.review.requested where it would have published, and the operator's decision arrives as app.review.decided (and app.published with via: 'review' on approval); turning the tenant's reviews off closes those pending as withdrawn, each recorded the same way. A withdrawal by the tenant closes its pending review without a decision of the operator's. A block of a module or of an app is recorded on every install it reaches, in that install's tenant, with the reason the operator gave, and its lifting the same way; the operator's own trail of these actions (operator_audit_events) is the instance's, which no tenant reads.

An endpoint's URL can hold the receiver's token in its path or its query (a Slack-style incoming webhook), so the webhook_endpoint.* events record its origin with /•••• for a path and ?•••• for a query (https://hooks.example.com/••••?••••); GET /webhook-endpoints/:id answers the whole URL to webhooks:read.

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, reviewId, 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, missingSecrets and secretPlaces (names), host, hosts, hostsAdded and hostsRemoved (hostnames, with a port that is not the default), 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 and review comment, the reason of case.closed, document.rejected and the operator's blocks (and their note), 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 seven 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.