# Analytics (/docs/api/analytics)

Endpoint that receives post messages to keep track of visitors and their progress. Used to calculate the conversion of tests.

Both **events** and **exposures** are posted here. The body is discriminated by a `type` field:

- `type: 'event'`: a real-world tracked action (a page view, click, purchase…). Events are **test-independent** and carry no `testId`/`testValue`; the server attributes them to a test/flag by joining against the visitor's exposures.
- `type: 'exposure'`: a visitor's assignment to a test/flag variant, emitted by the SDK the first time it resolves a non-holdout value for that visitor. This is what events are joined against.

## Url [#url]

`https://improve.obelism.studio/api/log`

- Method: `POST`

## Type [#type]

```ts
// Fields common to both bodies.
type BeaconCommon = {
	organizationId: string
	// Optional: the space whose allowed origins and API tokens apply. Without
	// it, the organization's first space.
	spaceId?: string
	environment: 'develop' | 'staging' | 'production'
	visitorId: string

	pointer: 'coarse' | 'fine'
	device:
		| 'wearable'
		| 'mobile'
		| 'tablet'
		| 'console'
		| 'smarttv'
		| 'embedded'
		| 'desktop'
	browser:
		| 'chrome'
		| 'safari'
		| 'firefox'
		| 'edge'
		| 'ie'
		| 'samsung internet'
		| 'social'
		| 'other'
	os: 'mac os' | 'ios' | 'android' | 'windows' | 'unix'
	screen: 'small' | 'medium' | 'large' | 'huge'
	visitor: 'recurring' | 'new'
}

// A real-world tracked event, test-independent (no testId/testValue).
type CreateEvent = BeaconCommon & {
	type: 'event'
	event: string
	// Required. The stable, structured second half of the event's identity —
	// e.g. "homepage_hero_login" — not display text. See postAnalytic.
	scope: string

	// GA4-aligned structured fields (all optional).
	// `value` (+ `currency`) is aggregated into revenue / average order value
	// per variant; `params` is stored and mirrored to the GTM dataLayer.
	value?: number | undefined
	currency?: string | undefined
	params?: Record<string, unknown> | undefined
}

// A visitor's assignment to a test/flag variant.
type CreateExposure = BeaconCommon & {
	type: 'exposure'
	subjectKind: 'test' | 'flag'
	subjectId: string
	variant: string
}

type createAnalytics = CreateEvent | CreateExposure
```

<Callout type="warn">
	This is a hard cutover: event bodies no longer carry `testId`/`testValue`, and
	older beacons that include them are rejected. Attribution is now derived
	server-side from exposures.
</Callout>

<Callout type="warn">
	Also a hard cutover: event bodies previously carried an optional `message`
	field, used inconsistently (a URL path on one event, a display label on
	another, often empty). It's replaced by a required `scope` — a short, stable
	identifier for where/what the event refers to, not display text. Beacons
	missing `scope` are rejected with `400`.
</Callout>

## Location [#location]

Both events and exposures are stored with a coarse location the server derives from the request's edge geo headers: Vercel's `x-vercel-ip-country` / `x-vercel-ip-country-region`, falling back to Cloudflare's `cf-ipcountry`:

- `country`: ISO 3166-1 alpha-2 (e.g. `NL`).
- `region`: subdivision code.

Both are **optional/nullable**: they're absent in local development and on hosts without a geo edge. The SDK never sends them, and the raw IP is **never stored**. Only these coarse values, computed at ingestion, are kept. `country` can be [targeted by audiences](/docs/api/datafile) (server-side only); `region` is captured for analytics but is **not** targetable.

## Authentication & limits [#authentication--limits]

Like the datafile, requests are authenticated by CORS (a browser request's `Origin` must be in the environment's Allowed origins) or by a `token` header for server requests; otherwise `403` (see [Security](/docs/api)).

The endpoint also validates and bounds the payload:

- Requests larger than **8KB** are rejected with `413`.
- Malformed JSON, a missing/unknown `type`, missing `organizationId`/`environment`, a value outside the enums above, a non-numeric `value`, a missing or empty `scope` on an event body, an event body that still carries `testId`/`testValue`, or an `event` name in the reserved `gtm.*` namespace returns `400`.
- Passing your plan's monthly quota does **not** affect this endpoint. Events are accepted and stored exactly as before — no event is ever dropped for being over quota. What pauses is the dashboard: test results stop being recalculated until the quota clears, at which point they catch up with no gap in the underlying data. See [Rate limits & quotas](/docs/api#rate-limits--quotas).

Every error response is JSON [problem details](/docs/api/errors) with a stable `code` and a `hint`, and still carries `success: false` for older clients.

The client SDK's `postAnalytic` fires these requests without awaiting the response, so these statuses are not surfaced to your app. Events rejected with a `4xx` are silently discarded.
