Skip to content

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.

Method · RoutePurpose
POST /api/public/apps/{applicationKey}/analytics/eventsAccept a batch of behavior events (202 Accepted)
  • Content-Type: application/json or text/plain (so navigator.sendBeacon works without a CORS preflight).
  • Origin required: the request’s Origin must be listed in your application’s Allowed Origins (CORS) (Console → Application → Allowed Origins). Unregistered origins get 403 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 returns 202 and discards, so your frontend never sees errors.
{
"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 / AppGroupId are derived from the applicationKey — never from the payload.
  • path keeps only the pathname; ref keeps only the domain. Raw IPs are not stored — only an ISO country code resolved at ingest.
  • Client ts is clamped to ±5 minutes of server time.
  • Unknown type values are silently dropped. custom.<name> accepts [a-z0-9_], max 40 chars, with a data object (≤2 KB serialized).
TypeWhen to sendKey fields
page_viewRoute/page becomes visiblepath, ref, us/um/uc (first page of the visit)
page_leavePage hidden/unloaded — use sendBeaconpath, dwell
identifyRight after a successful Axowl sign-inanon + 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 key
const 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:

  • visitorsUpperBound is 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. coveredDates versus requestedDates tells 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.

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.