Behavior analytics (beacon)
Axowl can collect behavior events from your app’s frontend — which pages your users visit, how long they stay, and where they came from (referrer / UTM). Because your app already authenticates users through Axowl, these events join cleanly to real end-user identity: the funnel first visit → sign-up → sign-in → return is measured on verified accounts, not cookie guesses.
Behavior events are stored in a dedicated columnar lane (Parquet on object storage), separate from your sealed audit events. Individual behavior events are not sealed; daily aggregates are anchored so the resulting counts are integrity-provable.
Ingest endpoint
Section titled “Ingest endpoint”| Method · Route | Purpose |
|---|---|
POST /api/public/apps/{applicationKey}/analytics/events | Accept a batch of behavior events (202 Accepted) |
- Content-Type:
application/jsonortext/plain(sonavigator.sendBeaconworks without a CORS preflight). - Origin required: the request’s
Originmust be listed in your application’s Allowed Origins (CORS) (Console → Application → Allowed Origins). Unregistered origins get403 ORIGIN_NOT_ALLOWED. - Limits: max 50 events per request, 64 KB body, 120 requests/min per app+IP. Oversized batches →
400; rate limit →429. - Toggle: collection can be disabled per app group (
BehaviorAnalyticsEnabled); when off the endpoint returns202and discards, so your frontend never sees errors.
Request body
Section titled “Request body”{ "events": [ { "id": "9f2c…", // client-generated uuid (dedup key) "type": "page_view", // page_view | page_leave | identify | custom.<name> "path": "/pricing", // query string is stripped server-side "ref": "https://google.com/search?q=…", // stored as domain only "us": "newsletter", // utm_source "um": "email", // utm_medium "uc": "aug-launch", // utm_campaign "dwell": 12840, // ms on page — page_leave only "anon": "a81b…", // anonymous id (localStorage uuid) "euid": "…", // end-user id, when signed in "sid": "s-77…", // session id (30-min rolling) "ts": "2026-08-09T12:00:00Z" } ]}Server-side rules (you cannot override these from the client):
OrganizationId/AppGroupIdare derived from theapplicationKey— never from the payload.pathkeeps only the pathname;refkeeps only the domain. Raw IPs are not stored — only an ISO country code resolved at ingest.- Client
tsis clamped to ±5 minutes of server time. - Unknown
typevalues are silently dropped.custom.<name>accepts[a-z0-9_], max 40 chars, with adataobject (≤2 KB serialized).
Event types
Section titled “Event types”| Type | When to send | Key fields |
|---|---|---|
page_view | Route/page becomes visible | path, ref, us/um/uc (first page of the visit) |
page_leave | Page hidden/unloaded — use sendBeacon | path, dwell |
identify | Right after a successful Axowl sign-in | anon + euid — links the anonymous history to the account |
custom.<name> | Your own milestones (e.g. custom.checkout_started) | data |
Minimal integration (until the SDK helper ships)
Section titled “Minimal integration (until the SDK helper ships)”const KEY = "ak_live_…"; // your application keyconst anon = localStorage.axAnon ??= crypto.randomUUID();const send = (events) => navigator.sendBeacon( `https://testapi.axowl.com/api/public/apps/${KEY}/analytics/events`, JSON.stringify({ events }));
send([{ id: crypto.randomUUID(), type: "page_view", anon, path: location.pathname, ref: document.referrer, ts: new Date().toISOString() }]);An @axowl/sdk analytics module (automatic SPA page tracking, dwell measurement, identify() on login, batching) and a standalone one-line beacon.js are in progress — this endpoint is their contract and is stable to build against today.
Reading the aggregates — GET /api/v1/admin/audit/behavior/rollup
Section titled “Reading the aggregates — GET /api/v1/admin/audit/behavior/rollup”What you send goes into a daily aggregate you can read back. Parameters: orgId (required),
days (1–90, default 30), and optional appGroupId to scope to one app group.
There is no add-on gate — the full 90-day window is free. Behavior analytics is metered on collection, not on looking at it. (This is a different axis from Audit Analytics, whose sealed compliance rollups have a 3-day preview without the add-on.)
{ "available": true, "days": 30, "coveredDates": 12, "requestedDates": 30, "daily": [{ "date": "08-13", "views": 412, "visitorsUpperBound": 260 }], "topPages": [{ "path": "/pricing", "views": 128, "visitorsUpperBound": 96, "avgDwellSec": 41.2 }], "referrers": [{ "domain": "news.ycombinator.com", "views": 88 }], "utmSources": [{ "source": "launch-email", "views": 51 }], "countries": [{ "country": "KR", "views": 190 }], "totals": { "views": 4120, "visitorsUpperBound": 2609, "identifiedUsersUpperBound": 418, "avgDwellSec": 37.4 }}Two honesty rules are worth reading before you build on these numbers:
visitorsUpperBoundis an upper bound, not a unique count. The daily rollup is keyed by page, referrer and country, so one person who reads three pages is counted once per page. Views and dwell time are exact; visitor counts are ceilings. The field names say so on purpose — do not relabel them as “unique visitors” downstream.- Missing days are omitted, not zero-filled.
coveredDatesversusrequestedDatestells you how much of the window actually has a rollup. A day with no rollup means “not built yet”, which is not the same claim as “nobody visited”.
Rollups are built once a day just after midnight UTC and backfilled from archived raw events, so coverage reaches as far back as your raw history rather than starting the day you enabled it. Today’s visits appear tomorrow.
Proving the numbers were not edited
Section titled “Proving the numbers were not edited”Each daily rollup emits a sealed anchor event (audit.rollup.behavior_created) carrying the
rollup’s SHA-256, its row and event counts, and a hash of the source file list. Individual behavior
events are not sealed — at page-view volume that would be neither affordable nor useful. The
integrity claim is made once per day, at the aggregate: this page-view count was computed over rows
that have not been altered.