Skip to content

Audit

Audit query — /api/v1/admin/audit (authenticated)

Section titled “Audit query — /api/v1/admin/audit (authenticated)”
Method · RoutePurposeSource
GET /query · /query/detailQuery audit eventsAdminAuditEndpoints.cs:39
GET /sessionsMember sessions:41
GET /stats · /stats/quickDashboard stats:42
GET /stats/forensicForensic query by email (scope to an app group); days no longer in the hot end-user store are read from the end-user archive (since 2026-09-24):44
GET /verify/{logId}Verify a single sealed record:46
GET /presignPresigned URL for an archived log file — raw batches or compacted day files; organization Owner with the unconstrained list scope, Pro or higher (since 2026-09-24, see below):38
GET /compact/urlsSigned URLs for the compacted day files in a window, so a browser can read long windows directly; same Owner + Pro rule (added 2026-09-24)AdminAuditEndpoints.cs
GET /retentionThis organization’s audit retention window:46
POST /heartbeatActive-time heartbeat:45
GET /analyticsAggregated usage analytics (added 2026-08-04)AdminAuditEndpoints.cs
GET /analytics/rollupSign-in outcomes and event volumes from sealed daily rollups (added 2026-08-04)AdminAuditEndpoints.cs
GET /behavior/rollupPages, dwell time, referrers and countries from sealed daily rollups (added 2026-08-14)AdminAuditEndpoints.cs

GET /query takes scope (default mine). Visibility is decided by scopes, not role labels, with one owner-only carve-out (updated 2026-08-24):

Caller holdsscope=minescope=all
Nothing audit-specific (default member grant org.audit_event_log.list:actor=self)Own events only403 PERMISSION_DENIED
Per-system grants org.audit_event_log.list_system:system=<key> (the Team Lead preset ships agent, project_mgmt, reporting)Own events onlyAll events of the granted systems plus the caller’s own events
Unconstrained org.audit_event_log.list (Owner/Admin hold this implicitly)Own events onlyThe whole organization

Two further conditions apply to scope=all (since 2026-09-23): the caller must be 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. A single query covers at most 90 days; the plan’s retention window (below) still applies inside that.

System keys map to event-type prefixes: agent → agent.*, project_mgmt → org.project.*, reporting → reporting.*, payment → payment.*.

payment.* 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.

Aggregated metrics behind the dashboard’s Audit Analytics page. Requires membership in the organization.

Query parameters:

ParamMeaning
orgIdOrganization id (required)
daysWindow length in days — clamped to 1–90, default 30
appGroupIdOptional. 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 hot window (orgAuditWindowDays, currently 3 days); longer horizons come from the archive path in a later phase.

Where the daily series comes from (windowSource, added 2026-08-16): windows beyond the 3-day preview read the daily activity / sign-up series and period totals from sealed daily rollups (the same files behind /analytics/rollup), bucketed by UTC day; only the current, incomplete day is filled from the hot store. rollupCoveredDates / rollupRequestedDates say how much of the window rollups actually cover — missing days chart as zero but are declared, not hidden. windowSource is "hot" for 3-day windows and app-group-filtered views (rollups carry no app-group axis), and "hot_fallback" when no rollups exist yet for the window. MAU and the identity-level widgets (top users / IPs, sign-in methods, per-app-group actives) still read the hot end-user store — daily rollups cannot express cross-day distinct users or those dimensions. The hot end-user store keeps 181 days (since 2026-09-26): twice the longest window (90 days) plus one, so the previous-period comparison of a 90-day view stays complete. Monthly active users on the billing and organization screens are counted from a per-day activity table instead, so they are not bounded by that retention.

Caching (added 2026-09-22): a completed day’s rollup never changes, so its parsed rows are cached per organization and day for up to 7 days; only days not yet in the cache are fetched from storage. A day with no rollup yet is remembered for 10 minutes, so a rollup that lands later shows up within that window. This applies to both /analytics and /analytics/rollup; it does not change any field of either response.

{
"days": 30,
"addonActive": true,
"freeWindowDays": 3,
"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 }],
"topUsers": [{ "email": "a@b.com", "displayName": "A", "logins": 88 }],
"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 (added 2026-10-16)

Section titled “Human seal marker — businessData._humanSeal (added 2026-10-16)”

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’s payloadHash and 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 /query and GET /query/detail already return businessData, so no new field or parameter was added. 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 the live tables. Same orgId / days parameters (1–90).

Two things this answers that the live query above 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.
  • Windows longer than the hot cache. The rollup is one small file per day, so ninety days costs about the same to read as one.
{
"available": true, "days": 30, "coveredDates": 12, "requestedDates": 30,
"daily": [{ "date": "07-28", "logins": 412, "failures": 9, "totalEvents": 980 }],
"failureReasons": [{ "reason": "invalid_credentials", "count": 61 }],
"totals": { "logins": 5210, "failures": 118, "attempts": 5328, "failureRatePct": 2.2 }
}

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" }.

Rollups are built once a day just after midnight UTC, and missing past days are backfilled automatically from the archived raw events (up to the 90-day window) — so coverage extends as far back as your organization has raw history, not just from the day the feature was enabled. Each day’s rollup is uploaded once and then never rewritten; re-running the builder skips days whose rollup already exists.

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) and an optional appGroupId.

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 visitorsUpperBound and identifiedUsersUpperBound for that reason. Views and dwell time are exact.

Full response shape and integration details live in the Behavior analytics guide.

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.

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.

PlanQueryable history
Free1 day
Pro7 days
Pro+30 days
Business90 days
EnterpriseSet 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.

The most recent three days are served from the hot store. Older days come from the sealed daily Parquet archive. Since 2026-09-22 a completed day is read from a single compacted per-day file when one exists (built nightly from that day’s sealed batches, sorted by time); days without one fall back to the individual batch files. Since 2026-09-23 a per-organization manifest (rebuilt nightly) additionally lists the days that hold no events at all, so a long window skips those days without touching storage, and the query server keeps a local copy of archive files it has already fetched (they never change once written), so reopening the same window does not download them again. This changes only how fast a long window answers — the rows, fields and visibility rules are the same either way.

Reading long windows in the browser (added 2026-09-24): for an organization Owner on Pro or higher viewing the whole organization without a keyword, the dashboard also fetches the compacted day files directly (signed URLs from /compact/urls) and evaluates the same query in the browser, keeping the files in the browser’s private storage for the next visit. The server’s answer is shown first; the browser only adds rows the server list left out (it caps at 500), merged by event id. Keyword searches stay on the server.

Who can download whole files (since 2026-09-24): a raw batch or a compacted day file holds every event of that organization for that period, including payment.*. Filtering rows is impossible once the file is handed over, so /presign 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.

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
}

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 with code: "UPGRADE_REQUIRED" and feature: "audit.retention".

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 seal assertion in the body; GET /retention reports canPurgeNow and the notice ledger (noticeCount, finalNoticeAt, purgeScheduledAt).

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 · RoutePurposeSource
POST /restore/{entityType}/{entityId}Restore a record to its sealed baselineIntegrityEndpoints.cs:246
POST /resolve/{entityType}/{entityId}Resolve a violation as false-positive (append-only; the original tampering_detected record is preserved):415