Audit
Audit query — /api/v1/admin/audit (authenticated)
Section titled “Audit query — /api/v1/admin/audit (authenticated)”Every route requires a signed-in member of the organization. Routes that take an orgId query
parameter (the organization id) answer 403 when the caller is not a member of that
organization.
| Method · Route | Purpose |
|---|---|
GET /query | Query audit events — parameters and visibility rules below |
GET /query/detail | One event with its full payload (orgId, logId, and date — the day of the event, needed to find events older than 3 days). Pro or higher — below Pro the server answers 403 {"error": "UPGRADE_REQUIRED", "feature": "feature.audit.detail"} |
GET /sessions | Member sessions (orgId, optional connectedId, startDate, endDate; default last 7 days, newest 500). Organization Owners and Admins holding the unconstrained list scope see every member’s sessions; anyone else sees only their own |
GET /stats · /stats/quick | Dashboard stats (orgId, days 1–90, default 7) |
GET /stats/forensic | Sessions and events of one end user, looked up by email (orgId, optional startDate, endDate, and groupId to scope to an app group; default last 7 days). Requires org.end_user.read (Owner/Admin hold it implicitly), else 403 PERMISSION_DENIED. On the Free plan the search is limited to 3 per month; past that the server answers 402 with code: "UPGRADE_REQUIRED". A range longer than 90 days answers 400 with code: "RANGE_TOO_LARGE" |
GET /stats/forensic/quota | This month’s end-user search quota: { "used", "limit", "unlimited" } (limit is -1 when unlimited) |
GET /verify/{logId} | Verify a single sealed record. Only for members of the record’s organization, and only for a record the caller could open with /query/detail (else 403 PERMISSION_DENIED) |
GET /files | List the archived log files of a date range (orgId, startDate, endDate; default last 7 days, at most 90 days — see the range rule below) |
GET /sealer | List the organization’s seal-record files (orgId) |
GET /presign | Signed URL, valid for 5 minutes, for one archived file (key). Archived log files, compacted day files and seal-record files are limited to the organization Owner with the unconstrained list scope, on Pro or higher (see Downloading whole files) |
GET /content | The content of one archived file (key), under the same rule as /presign |
GET /compact/urls | Signed URLs for the compacted day files in a window; same Owner + Pro rule |
GET /retention | This organization’s audit retention window |
POST /retention/purge-now | Delete history that is already outside the retention window (see After the window) |
POST /heartbeat | Active-time heartbeat |
GET /analytics | Aggregated usage analytics |
GET /analytics/rollup | Sign-in outcomes and event volumes from sealed daily rollups |
GET /behavior/rollup | Pages, dwell time, referrers and countries from sealed daily rollups |
Query parameters — GET /query
Section titled “Query parameters — GET /query”| Param | Meaning |
|---|---|
orgId | Organization id (required) |
startDate · endDate | Time range. endDate defaults to now and startDate to 7 days before it; a date without a time includes that whole day |
scope | mine (default) or all — see below |
eventType | One event type |
eventTypes | Comma-separated list of exact event types |
keyword | Substring match on event type, target email, IP address and businessData |
actor | Comma-separated actor ids. Only honoured with scope=all for callers who see the whole organization; otherwise the caller’s own ids are used |
page · pageSize | Paging. page starts at 1; pageSize defaults to 100 and is capped at 500 |
light | Optional boolean. Returns only the fields a list view needs |
summaryOnly | Optional boolean. Returns counts per event type instead of individual events |
The response is { "events": [...], "total": n, "page": n, "pageSize": n }. The plan’s retention
window (below) applies to the requested range.
One call covers at most 90 days. After the range is clamped to the retention window, a range
that is still longer answers 400 with code: "RANGE_TOO_LARGE" and maxDays: 90, and a
startDate after endDate answers 400 with code: "INVALID_RANGE"; error carries the
message. A future endDate does not count toward the limit. GET /files applies the same rule, and
GET /stats/forensic applies the 90-day limit.
Who sees which events — scope=mine|all
Section titled “Who sees which events — scope=mine|all”GET /query takes scope (default mine). Visibility is decided by scopes, not role labels,
with one owner-only carve-out:
| Caller holds | scope=mine | scope=all |
|---|---|---|
Nothing audit-specific (default member grant org.audit_event_log.list:actor=self) | Own events only | 403 PERMISSION_DENIED |
Per-system grants org.audit_event_log.list_system:system=<key> (the Team Lead preset ships agent, project_mgmt, reporting) | Own events only | All events of the granted systems plus the caller’s own events |
Unconstrained org.audit_event_log.list (Owner/Admin hold this implicitly) | Own events only | The whole organization |
Two further conditions apply to scope=all: seeing the whole organization requires an
organization Owner or Admin — a Member who was granted the unconstrained scope still gets
403 PERMISSION_DENIED — and the organization must be on Pro or higher. Below Pro the server
answers 403 {"error": "UPGRADE_REQUIRED", "feature": "feature.audit.view_all"}; the dashboard
shows an upgrade prompt in place and falls back to the caller’s own records. Every plan keeps
scope=mine (the default) fully available.
System keys map to event-type prefixes: agent → agent.*, project_mgmt → org.project.*,
reporting → reporting.*, payment → payments.*.
payments.* events are visible to the organization owner only. This is a carve-out, not a
scope: admins and per-system holders never receive them on any path (list, detail, or archive),
mirroring the billing owner-only rule.
GET /query/detail applies the same judgment per event — knowing a logId does not open an
event you could not see in the list.
Usage analytics — GET /analytics
Section titled “Usage analytics — GET /analytics”Aggregated metrics behind the dashboard’s Audit Analytics page. Requires membership in the organization.
Query parameters:
| Param | Meaning |
|---|---|
orgId | Organization id (required) |
days | Window length in days — clamped to 1–90, default 30 |
appGroupId | Optional. Scopes end-user metrics to one app group |
Two layers with different scopes, matching the data model:
- End-user metrics (daily active users, logins, sign-ups, sign-in method mix, top users / IPs, per-app-group actives) come from end-user events, which carry an app-group axis. Every KPI ships with the previous-period value so clients can render a change badge.
- Organization audit metrics (
orgEventTypes) come from member/system audit events and are always organization-wide — those records carry no app-group axis. They cover only the most recent 3 days (orgAuditWindowDays) and list the 12 most frequent event types.
Where the daily series comes from (windowSource): windows longer than 3 days read the daily
activity / sign-up series and the period totals from sealed daily rollups (the same data
behind /analytics/rollup), bucketed by UTC day; the current, incomplete day is added from
live events. rollupCoveredDates / rollupRequestedDates say how much of the window rollups
actually cover — missing days chart as zero but are declared, not hidden. windowSource is:
| Value | Meaning |
|---|---|
"rollup" | Daily series and totals come from sealed daily rollups (UTC days) |
"hot" | Windows of 3 days or less, and app-group-filtered views (rollups carry no app-group axis). Days are bucketed in the organization’s timezone; offsetMinutes is its UTC offset |
"hot_fallback" | A longer window for which no rollup exists; served from live events like "hot" |
MAU and the identity-level widgets (top users / IPs, sign-in methods, per-app-group actives) are
always computed from end-user sign-in events, which are kept for 181 days — enough for a
90-day window and its previous-period comparison. topUsers and topIps hold at most 10 entries.
A day’s rollup can take up to 10 minutes to show up in /analytics and /analytics/rollup after
it has been built.
{ "days": 30, "addonActive": true, "freeWindowDays": 3, "offsetMinutes": 540, "windowSource": "rollup", "rollupCoveredDates": 27, "rollupRequestedDates": 29, "appGroups": [{ "id": "…", "name": "My App", "eventsEnabled": true }], "kpi": { "mau": 812, "mauPrev": 640, "logins": 5210, "loginsPrev": 4102, "signups": 96, "signupsPrev": 71, "dauToday": 143 }, "daily": [{ "date": "07-06", "logins": 180, "dau": 84, "signups": 3 }], "providers": [{ "provider": "Passkey", "count": 3120 }], "byAppGroup": [{ "appGroupId": "…", "name": "My App", "activeUsers": 490, "logins": 3800 }], "topIps": [{ "ip": "203.0.113.9", "count": 412, "users": 12 }], "orgEventTypes": [{ "eventType": "org.member.invited", "count": 12, "sealedCount": 12 }], "orgAuditWindowDays": 3}One honesty rule worth knowing: app groups with event collection turned off report zeros because nothing is recorded — the dashboard badges this rather than letting the number read as “no activity”.
Human seal marker — businessData._humanSeal
Section titled “Human seal marker — businessData._humanSeal”An audit record can be written because a person approved something with a fingerprint (passkey)
seal, or without any human seal: automatic system work, a signed-in session, an API key or an AI
agent. Records in the first group carry a marker inside businessData:
{ "_humanSeal": { "credentialId": "AbCdEfGhIjKlMnOp…", "userId": "11111111-…" } }- The marker is set when the request that produced the record passed a passkey step-up seal. Records produced outside a request, such as background workers and batches, never carry it.
- It lives inside
businessData, so it is part of the record’spayloadHashand chain hash. Removing or adding it later breaks verification like any other edit. - Records without the marker keep exactly their previous shape. Records written before 2026-10-16 have no marker even if a seal was used.
GET /queryandGET /query/detailreturn the marker as part ofbusinessData; there is no separate field or parameter for it. The dashboard shows it as Authorized by: Human seal / Automatic · session in the Integrity Audit table and the record detail.- The marker says a human seal passed in that request. Whether the seal was bound to the exact content approved is a separate property of the step-up binding.
Sign-in outcomes — GET /analytics/rollup
Section titled “Sign-in outcomes — GET /analytics/rollup”Sign-in success and failure over time, read from sealed daily rollups rather than from live
events. Same orgId / days parameters (1–90, default 30) and the same membership requirement as
/analytics. The window ends yesterday (UTC): a rollup covers a completed day.
Two things this answers that /analytics cannot:
- Failed attempts on accounts that don’t exist. End-user event rows require a real user, so an attempt against an unknown email — the signature of credential stuffing — can’t be stored there. The rollup is built from the audit chain, which has no such constraint, so those attempts are counted.
- Organization events over long windows.
/analyticsreports organization audit events for the last 3 days only; the rollup covers the whole window.
{ "available": true, "days": 30, "coveredDates": 12, "requestedDates": 30, "daily": [{ "date": "07-28", "logins": 412, "failures": 9, "totalEvents": 980 }], "failureReasons": [{ "reason": "invalid_credentials", "count": 61 }], "eventTypes": [{ "eventType": "org.member.invited", "count": 40 }], "byActor": [{ "actorType": "member", "count": 900 }, { "actorType": "agent", "count": 80 }], "agentEventTypes": [{ "eventType": "agent.action_denied", "count": 3 }], "agentDaily": [{ "date": "07-28", "count": 7 }], "actorAxisAvailable": true, "totals": { "logins": 5210, "failures": 118, "attempts": 5328, "failureRatePct": 2.2 }, "files": { "daily": [{ "date": "07-28", "saved": 14, "opened": 31, "sharedViews": 2, "sealed": 1, "trashed": 0 }], "totals": { "saved": 180, "opened": 402, "sharedViews": 25, "sealed": 9, "trashed": 6 } }}eventTypes lists the 15 most frequent event types of the window. byActor splits the same
events by who acted (member, endUser, agent, system, external); events whose rollup
carries no actor type are counted as "unknown", and
actorAxisAvailable is false when no rollup in the window carries one. agentEventTypes and
agentDaily cover only events performed by AI agents.
files counts file-box activity from the same rollup — uploads (saved), download links issued
(opened: Axowl knows a link was handed out, not that the file was read), views through a public
share link, seals and moves to trash. Who did each one is in the audit log itself, not here.
coveredDates is the number of days that actually had a rollup — days without one are left out
rather than filled with zeros, because a zero would read as “nothing happened” when the truth is
“we don’t know yet”. When no rollup exists for the window at all, the response is
{ "available": false, "reason": "no_rollups_yet", "days": 30, "message": "…" }.
Rollups are built once a day just after midnight UTC, and missing recent days are backfilled automatically from the archived raw events — so coverage is not limited to the days since the feature was enabled.
Visitor behavior — GET /behavior/rollup
Section titled “Visitor behavior — GET /behavior/rollup”Pages, dwell time, referrers, campaign sources and countries for an app group, read from the same
kind of sealed daily rollup. Takes orgId, days (1–90, default 30) and an optional appGroupId.
Requires an active member of the organization.
This route is on a different axis from the two above: it reports what visitors did on your site, collected through the behavior beacon, rather than what happened inside Axowl’s audit chain. Two consequences worth knowing:
- No add-on gate. The full 90-day window is free — behavior analytics is metered on collection, not on reading. The 3-day preview clamp described under Access does not apply here.
- Visitor counts are upper bounds. Rollup rows are keyed by page, referrer and country, so one
visitor reading three pages is counted once per page. The fields are named
visitorsUpperBoundandidentifiedUsersUpperBoundfor that reason. Views and dwell time are exact.
Full response shape and integration details live in the Behavior analytics guide.
Access
Section titled “Access”The two audit analytics routes — /analytics and /analytics/rollup — work without the
Audit Analytics add-on, but only over a 3-day
preview window: for organizations that don’t own the add-on, the server clamps days to 3
regardless of what you request, and the /analytics response says so with
"addonActive": false, "freeWindowDays": 3. The add-on opens the full 90-day window. The clamp
is enforced server-side — passing days=90 without the add-on returns 3 days, not an error.
The add-on is $99/month flat and is enabled from the dashboard: Billing → Add-ons →
Audit Analytics → Enable. It is billed from the organization’s prepaid credit (a 402 on
enable means the balance is short) and, like other paid add-ons, requires the Pro plan or
higher (403 UPGRADE_REQUIRED on Free).
Audit retention is a plan limit and is unrelated — retention decides how far back history exists, the add-on decides how much of it your dashboards can read.
GET /behavior/rollup is not covered by any of the above — it has no add-on requirement and no
preview clamp.
Retention
Section titled “Retention”How far back audit history can be queried depends on the organization’s plan. Every event is sealed and notarized on every plan — retention limits how far back you can look, not whether we record. Sealing carries no volume limit and no per-event fee.
| Plan | Queryable history |
|---|---|
| Free | 3 days |
| Pro | 7 days |
| Pro+ | 30 days |
| Business | 90 days |
| Enterprise | Set per contract (unlimited by default) |
The same window applies to Request & Approval records. Approvals that are still in flight are never hidden — retention only applies once a request is completed, rejected, or acknowledged.
Downloading whole files
Section titled “Downloading whole files”GET /query answers recent and archived days alike — the rows, fields and visibility rules are
the same however old the day is — and returns at most 500 rows per page. To read a long window
in one pass, a client can fetch the archived files themselves and evaluate the query locally; the
dashboard does this for an organization Owner viewing the whole organization.
GET /compact/urls takes orgId and an optional startDate / endDate (default: the last 7
days; at most 90 days per call, clamped to the retention window) and returns one signed URL
per compacted day file. Each file is a Parquet file holding all events of one UTC day; the most
recent two days have no compacted file yet. The URLs are valid for 5 minutes.
{ "manifest": true, "from": "20260901", "to": "20260928", "days": [{ "date": "20260901", "url": "https://…", "rows": 1840, "bytes": 96211 }]}When manifest is true, days lists only the days that have a compacted file, with their row
and byte counts. When it is false, every day of the window is listed with rows and bytes
set to -1, and a URL may answer 404 for a day without a file.
Who can download whole files: an archived log file or a compacted day file holds every event
of that organization for that period, including payments.*. Filtering rows is impossible once
the file is handed over, so /presign, /content and /compact/urls apply the strictest rule
that covers the file’s contents — Owner with the unconstrained list scope, on Pro or
higher (403 PERMISSION_DENIED / 403 UPGRADE_REQUIRED). Admins read the same events row by
row through /query.
How the window is enforced
Section titled “How the window is enforced”GET /retention returns the window so a UI can bound its date picker before querying:
{ "planKey": "plan.pro", "retentionDays": 7, "unlimited": false, "windowStartUtc": "2026-07-21T09:14:00Z", "isContractOverride": false, "graceDays": 20, "noticeCount": 1, "noticeCountPolicy": 2, "noticeIntervalDays": 10, "firstNoticeAt": "2026-09-22T09:00:00Z", "finalNoticeAt": null, "purgeScheduledAt": "2026-10-12T00:00:00Z", "purgeUnlocked": false, "canPurgeNow": true, "purgeCutoffUtc": "2026-07-01T09:14:00Z"}retentionDays is -1 and windowStartUtc and purgeCutoffUtc are null when retention is
unlimited. purgeCutoffUtc is windowStartUtc minus graceDays: history older than it is due
for deletion. canPurgeNow is true only for an organization Owner on a plan with a finite
window.
On /query, /query/detail, and /stats/forensic:
- A range that partly predates the window is clamped to
windowStartUtc; you get the portion inside the window. - A range that lies entirely outside the window returns
402 Payment Required:
{ "error": "Your plan retains audit history for 7 day(s). The requested range is entirely outside that window. Upgrade to look further back.", "code": "UPGRADE_REQUIRED", "feature": "audit.retention", "planKey": "plan.pro", "retentionDays": 7, "windowStartUtc": "2026-07-21T09:14:00Z"}After the window: grace, then deletion
Section titled “After the window: grace, then deletion”Events that fall out of the window are not deleted immediately. When history first leaves the
window, the organization’s owner is emailed twice, 10 days apart. The second email is an
action-required notice that states the deletion date. If the plan is not upgraded within 10 days
after that notice (20 days in total, graceDays), the expired history is permanently deleted.
Upgrading at any point before then restores access instantly. History that leaves the window later
is deleted on the same schedule without further emails; a new notice cycle only starts if the plan’s
retention period changes.
An owner can also delete expired history right away from the console (audit dashboard → “Delete
expired history now”), which requires a seal (passkey). The same call is available as
POST /retention/purge-now with the body { "orgId": "…", "assertionResponseJson": "…" }
(the passkey seal assertion); GET /retention reports canPurgeNow and the notice ledger
(noticeCount, finalNoticeAt, purgeScheduledAt). It deletes everything older than
windowStartUtc and answers
{ "purged": true, "eventCount": 1840, "tombstoneId": "…", "detail": "deleted" }; purged is
false, with the reason in detail, when nothing is outside the window, retention is
unlimited, or the organization is under a legal hold. Errors: 403 with code: "OWNER_ONLY"
for a caller who is not an Owner, and 401 with code: "SEAL_REQUIRED" when the seal is
missing or was issued for a different action. In an organization with a Roost Auditor, only an
Auditor can make this call (403 AUDITOR_REQUIRED otherwise).
Only after the grace period is the underlying archive permanently deleted, and the owner is emailed again. Deletion is itself recorded: a sealed tombstone is written before anything is removed, carrying the Merkle root of the deleted events plus the integrity hashes on either side of the gap. That means:
- A record you still hold a copy of can be proven to have belonged to the deleted range, via its payload hash.
- The audit chain has a signed bridge across the gap rather than an unexplained hole — deletion under policy stays distinguishable from tampering.
Per-event verification (GET /verify/{logId}, and client-side “verify on my device”) is unaffected
by deletion of other events: each record’s payload hash and signature are self-contained.
Organizations under a legal hold are never purged, regardless of plan.
Integrity violations — /api/v1/admin/integrity (authenticated)
Section titled “Integrity violations — /api/v1/admin/integrity (authenticated)”| Method · Route | Purpose |
|---|---|
GET /open-violations | Unresolved tampering detections in the caller’s organizations (newest 50): { "violations": [{ "entityType", "entityId", "organizationId", "hashField", "storedHash", "detectedAt", "summary", "snapshotId", "snapshotAt" }] } |
GET /snapshot/{entityType}/{entityId} | The latest sealed snapshot of a record, for comparing against its current state. Requires org.integrity_snapshot.read in the record’s organization (Owner/Admin hold it implicitly), else 403; 404 when no snapshot exists |
POST /restore/{entityType}/{entityId} | Restore a record to its sealed baseline. Organization Owner only (403 otherwise). Body { "reason": "…" } — required, 400 without it; 404 when there is no snapshot to restore from |
POST /resolve/{entityType}/{entityId} | Resolve a violation as false-positive (append-only; the original tampering_detected record is preserved). Organization Owner only; body { "reason": "…" } is required |
entityId is a GUID. A successful restore answers
{ "ok": true, "entityType", "entityId", "restoredFromSnapshot", "sealedAt", "restoredBy" } and a
successful resolve { "ok": true, "entityType", "entityId", "resolvedBy", "reason" }; both are
recorded in the audit log.