API
Analytics
Post events and exposures to /api/log. Improve joins them to calculate test results.
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 notestId/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 | CreateExposureWatch 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, missingorganizationId/environment, a value outside the enums above, a non-numericvalue, a missing or emptyscopeon an event body, an event body that still carriestestId/testValue, or aneventname in the reservedgtm.*namespace returns400. - 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.