Skip to Content

API

Analytics

Post events and exposures to /api/log. Improve joins them to calculate test results.

4 min read

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

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

  • Method: POST

Type

// 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

Watch out

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.

Watch out

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.

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 (server-side only); region is captured for analytics but is not targetable.

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).

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.

Every error response is JSON problem details 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.

On this page

Marks in the margin

  • Note
  • Watch out
  • Updated

Scribes marked doubtful lines in the margin. That practice is called obelism.