API Reference
Practical reference for the current Logga backend route surface.
This page is a practical guide to the route surface currently wired in logga-backend/src/index.ts.
Base Concepts
Auth modes
| Mode | Typical caller | Notes |
|---|---|---|
| Dashboard auth | Web dashboard or native user flows | Human user JWT-based access. |
| API key auth | Backend or service integration | Used for ingest and other workspace-scoped automation. |
| Hybrid auth | Either of the above | Route decides from the auth context which workspace and environment are active. |
| MCP token | An AI assistant | A lg_mcp_ key on the /mcp endpoint. See AI Connectors. |
Scopes
An API key carries an explicit list of scopes. A request whose key lacks the scope its route needs is
answered 403 insufficient_scope, naming the scope that was missing. Session tokens are not scoped —
their authorisation is workspace membership.
Keys created before scopes existed carry an empty list, which means unrestricted.
| Scope | Grants |
|---|---|
events:read / events:write |
GET / everything else on /v1/events |
channels:read / channels:write |
/v1/channels |
sessions:read / sessions:write |
/v1/sessions |
rules:read / rules:write |
/v1/rules |
analytics:read |
/v1/stats |
notifications:read |
/v1/notifications |
users:read |
tracked-user data through MCP |
users:link |
POST /v1/users/link. Never on an app key |
devices:write |
/v1/device-tokens |
flags:read |
GET /v1/flags, POST /v1/flags/:key/exposure |
flags:manage |
/v1/feature-flags: flags with their rules, changes and A/B results. Never on an app key |
Common route families
| Route family | Auth |
|---|---|
/health |
none |
/auth/* |
mixed, depending on route |
/v1/dashboard/* |
dashboard auth |
/v1/events |
hybrid auth |
/v1/channels |
hybrid auth |
/v1/sessions |
hybrid auth |
/v1/stats |
hybrid auth |
/v1/rules |
hybrid auth |
/v1/notifications |
hybrid auth |
/v1/device-tokens |
hybrid auth |
/v1/reports |
dashboard auth |
/mcp |
MCP token |
Health
GET /health
Returns a basic liveness payload.
Example response:
{
"status": "ok",
"timestamp": "2026-03-19T12:00:00.000Z"
}Authentication
POST /auth/register
Creates a user, workspace, workspace membership, and a default project for the production environment.
Example body:
{
"email": "[email protected]",
"password": "very-secret-password",
"name": "Alex",
"workspaceName": "Acme"
}POST /auth/login
Returns a user token plus a basic user payload.
GET /auth/me
Returns the authenticated user plus workspace memberships and environment summaries.
Events
POST /v1/events
Create an event in the authenticated workspace and environment.
Headers:
Authorization: Bearer lg_sk_xxxx
Content-Type: application/jsonExample body:
{
"channel": "payments",
"event": "invoice_sent",
"level": "success",
"tags": ["stripe", "pro"],
"metadata": {
"amount": 49,
"currency": "EUR"
},
"actor": {
"id": "user_123",
"name": "Francesco",
"email": "[email protected]",
"role": "admin",
"properties": {
"plan": "pro",
"seats": 5
}
},
"idempotencyKey": "invoice_abc123"
}Notable validation behavior:
channelis requiredeventis requiredleveldefaults toinfometadatais capped at 10KBidempotencyKeyis optional but globally unique when providedactor.idis preferred overuserIdwhen both are sentactor.propertiesvalues must be strings, numbers, booleans, or null; keys ≤64 chars, string values ≤1024 chars, max 50 keys per user- events upsert a row in the tracked users table (
TrackedUser), keyed on(projectId, actor.id), and merge any identity fields carried in the event actor.propertiesis a patch: sent keys are written, missing keys are kept,nullremoves a key. Properties that break the limits, or would take the user past 50 keys, are not stored; the event still is, and the201body carrieswarnings: ["actor.properties ignored: ..."]
GET /v1/events
Supported query filters include:
channelleveluserIdsessionIdfromtolimitcursortagsprojectId
GET /v1/events/:id
Fetch a single event scoped to the authenticated workspace and environment.
DELETE /v1/events/:id
Delete one event.
DELETE /v1/events
Bulk delete up to 200 event ids in one request.
Sessions
POST /v1/sessions
Creates a new session.
Example body:
{
"channel": "system",
"name": "nightly_build",
"metadata": {
"branch": "main",
"commit": "abc123"
},
"ttlSeconds": 1800
}PATCH /v1/sessions/:id
Updates session progress, current step, or metadata.
POST /v1/sessions/:id/complete
Marks the session complete and may include a summary.
POST /v1/sessions/:id/fail
Marks the session failed and may include a reason.
POST /v1/sessions/:id/close
Closes the session without marking it successful.
Rules
GET /v1/rules
Lists alert rules for the authenticated workspace and environment.
Optional filters:
channelIdisActive- cursor pagination fields
POST /v1/rules
Creates an alert rule.
Example body:
{
"name": "Alert on payment failures",
"channelId": "channel_uuid",
"condition": {
"type": "on_event",
"level": "error"
}
}PATCH /v1/rules/:id
Updates name, target channel, condition, or isActive.
Reports
Reports are dashboard-auth-only today.
GET /v1/reports
Query parameters:
workspaceId- optional
environmentId - pagination fields
POST /v1/reports
Creates a scheduled report.
Example body:
{
"workspaceId": "workspace_uuid",
"environmentId": "workspace_uuid_production",
"name": "Daily payment volume",
"schedule": "daily",
"metric": "sum",
"aggregateField": "amount",
"filters": {
"eventType": "payment_succeeded",
"tags": ["payments"]
},
"groupBy": null,
"channelIds": ["channel_uuid"]
}PATCH /v1/reports/:id
Updates report configuration. Aggregate metrics require aggregateField.
DELETE /v1/reports/:id
Deletes a report config.
Tracked Users
POST /v1/users/link
Hybrid auth, scope users:link. Body { "previousId": "v_8f3…", "userId": "user_123" }: the anonymous id a visitor had and the user id they got when they signed up are one person from now on. Call it from your backend at signup, where the account is created.
What it does, once: previousId's events, profile (the earlier first-seen, the visitor's country and properties where the user has none) and feature-flag exposures move to userId. Afterwards events and flag reads still sent with previousId land on userId, and looking up previousId returns the user, with linkedIds listing the ids that were linked to it.
In an A/B test the first exposure counts: if the visitor saw the new version, the signed-in user keeps seeing it and stays in that group, and the goal they reach after signing up is counted there. A goal like user_signed_up can therefore be measured on logged-out visitors.
{ "previousId": "v_8f3…", "userId": "user_123", "alreadyLinked": false, "eventsMoved": 14, "exposuresMoved": 1 }The rules that keep one person from taking another's history:
- a link is permanent: calling it again with the same pair is harmless (
alreadyLinked: true), linking the samepreviousIdto a different user is409 already_linked; - an id that other ids were linked to is a known user and can never be linked away:
409 previous_id_is_identified; users:linkis never on an app key: a browser or an app can claim any id, the server that created the account cannot.
Two ids that never went through a link look alike to Logga, so pass the anonymous id as previousId, never a real user id. previousId and userId equal is 422.
Dashboard Project Users
Project users are dashboard-auth-only and scoped to a single project inside the selected environment.
GET /v1/dashboard/projects/:projectId/users
Returns a paginated tracked-user list plus page summary.
Query parameters:
workspaceId- optional
environmentId limit- optional opaque
cursor - optional
query - optional
range:24h,7d,30d,all - optional
sort: currentlylast_seen_desc
Response shape:
{
"summary": {
"trackedUsers": 42,
"activeUsersInRange": 17,
"eventsInRange": 382,
"unattributedEventsInRange": 21
},
"data": [
{
"userId": "user_123",
"name": "Francesco",
"email": "[email protected]",
"avatarUrl": null,
"role": "admin",
"label": null,
"properties": {
"plan": "pro",
"seats": 5
},
"lastSeenAt": "2026-04-09T09:15:00.000Z",
"eventCount": 18,
"sessionCount": 4,
"channelCount": 3,
"lastEventType": "signed_in"
}
],
"nextCursor": "opaque_cursor"
}GET /v1/dashboard/projects/:projectId/users/detail
Returns one tracked user detail payload.
Query parameters:
workspaceId- optional
environmentId - required
userId - optional
range:24h,7d,30d,all - optional
eventsLimit - optional
eventsCursor
The detail response includes:
- stable user identity, including
properties(custom key-value metadata) - all-time
firstSeenAt - all-time
lastSeenAt - range-scoped totals
- top channels
- top event types
- recent events with event-style pagination
PATCH /v1/dashboard/projects/:projectId/users
Updates identity fields and custom properties for a tracked user. All fields except workspaceId and userId are optional; only the fields you pass are updated.
Body:
{
"workspaceId": "workspace_uuid",
"environmentId": "environment_id",
"userId": "user_123",
"name": "Francesco",
"email": "[email protected]",
"avatarUrl": "https://…",
"role": "admin",
"label": "Pro tier",
"properties": {
"plan": "pro",
"seats": 5,
"trial": false
}
}Response mirrors the request's identity fields and the full stored properties object.
Validation:
name≤255,emailmust parse as email,avatarUrlmust parse as URL,role≤128,label≤255propertieskeys ≤64 chars, values must be strings/numbers/booleans/null, string values ≤1024 chars, max 50 keys- empty strings are normalized to
nullso the editor can clear a field propertieshere is a full replacement: send the complete object. Events work the other way, patching only the keys they carry
Note: subsequent events carrying actor.name, actor.email, etc. will overwrite what you save here. See Tracking Users In The Dashboard for how to reason about the source of truth.
Analytics
Dashboard auth. Every body carries workspaceId (and optionally environmentId); the project must belong to that workspace and environment or the call returns 404. Queries time out after 10 seconds.
POST /v1/analytics/query
Aggregates over events: count, count_distinct, sum, avg, min, max, p50, p95, p99, grouped by channel, eventType, level, userId, actor, metadata.<path>, time:<bucket> or user.<property>.
userFilters keeps only events whose user matches, using the same operators as metadata rules (exists, not_exists, equals, not_equals, contains, gt, gte, lt, lte):
{
"workspaceId": "…",
"projectId": "…",
"aggregations": [{ "op": "count", "alias": "reports" }],
"filters": { "eventType": "report_sent" },
"userFilters": [{ "field": "mrr", "operator": "gt", "value": 100 }],
"groupBy": ["user.plan", "time:week"]
}User properties are read as they are now, not as they were when the event happened.
POST /v1/analytics/funnel
How many users did a sequence of events, in order. A user enters at their first step-1 event between from and to; each later step must happen strictly after the previous one and within conversionWindowSeconds of step 1 (default 7 days, max 90). Users are followed by the event's user id, so events without one are ignored.
{
"workspaceId": "…",
"projectId": "…",
"steps": [
{ "eventType": "user_signed_up" },
{ "eventType": "account_added" },
{ "eventType": "report_created", "metadataRules": [{ "source": "metadata", "field": "type", "operator": "equals", "value": "weekly" }] }
],
"from": "2026-08-01T00:00:00Z",
"to": "2026-09-01T00:00:00Z",
"conversionWindowSeconds": 604800,
"userFilters": [{ "field": "is_agency", "operator": "equals", "value": true }]
}Response: one entry per step with users, conversionFromStart, conversionFromPrevious and medianSecondsFromPrevious.
POST /v1/analytics/retention
Do users come back. Each user joins the cohort of the day, week or month in which they first did startEvent, ever; cohorts are the ones whose first occurrence falls between from and to (at most 60). For each later period it counts the users who did returnEvent; omit returnEvent to count any event.
{
"workspaceId": "…",
"projectId": "…",
"startEvent": { "eventType": "user_signed_up" },
"returnEvent": { "eventType": "dashboard_viewed" },
"period": "week",
"from": "2026-07-01T00:00:00Z",
"to": "2026-09-01T00:00:00Z",
"periods": 8
}Response: cohorts[] with cohortStart, size, retained[] and rates[], where index 0 is the cohort itself, plus average[] weighted by cohort size. A null means that period has not started yet; it is not zero retention.
The same three questions are available to AI agents over MCP as run_analytics_query, run_funnel_query and run_retention_query.
Device Tokens
POST /v1/device-tokens
Registers or updates an iOS device token for the active workspace and environment.
Example body:
{
"token": "apns_device_token_hex",
"workspaceId": "workspace_uuid",
"environmentId": "workspace_uuid_production",
"platform": "ios"
}Important behavior:
workspaceIdmust match the authenticated workspace- if
environmentIdis supplied, it must match the authenticated environment platformis currentlyiosonly
API Keys
GET /v1/dashboard/api-keys
Lists the keys for a workspace and environment. Returns keyPrefix, never the key: the server stores
a SHA-256 digest and cannot produce the original.
POST /v1/dashboard/api-keys
Creates a key. The response is the only place the raw value ever appears.
Example body:
{
"workspaceId": "workspace_uuid",
"environmentId": "workspace_uuid_production",
"type": "mcp",
"name": "Claude Desktop",
"scopes": ["events:read", "analytics:read"],
"expiresInDays": 90
}| Field | Notes |
|---|---|
environmentId |
Optional. Defaults to the workspace's default environment. |
projectId |
Optional. Confines the key to one project. Omitted means every project in the environment, which is what every key created before this field is. |
type |
secret (default) for an ingest key, mcp for an AI connector. Decides the key's prefix. |
name |
Human label. label is accepted as the older name for this field. |
scopes |
Optional. Omitted means events:write/sessions:write/devices:write/flags:read when projectId is set, everything for a secret without one, and read plus events:write/sessions:write/rules:write for mcp. Unknown scopes are dropped rather than rejected. |
expiresInDays |
Optional hard expiry. An expired key is rejected exactly like a revoked one. |
Keys for a distributed client
A key compiled into a mobile or desktop app is handed to everyone who downloads it, so treat it as
public. Give it a projectId and let the scopes default: it can then write its own project's events
and read its own project's flags, and read no data at all.
Scopes alone are not enough. They say what a key may do and never whose data it may do it to: before
projectId existed, a key made for one app could read every other project in the workspace, which is
how a key created for one iOS app returned another app's events from /v1/stats on 2026-08-31.
A bound key behaves like this:
- an unqualified request resolves to its project, not to the environment's default;
?projectId=naming any other project answers403 project_forbidden;- over MCP it sees its project alone, and
list_projectsreturns only that one.
DELETE /v1/dashboard/api-keys/:id
Soft revocation. The key stops authenticating immediately; the row survives so lastUsedAt and the
creation date stay available after a rotation.
Feature Flags
Product guide: Feature Flags.
GET /v1/flags
Hybrid auth, scope flags:read. Every flag of the project, evaluated for one user.
| Query | Notes |
|---|---|
userId |
Optional. The same id sent with events. Empty or absent means a logged-out visitor: only flags at 100% are on. An id linked with POST /v1/users/link reads as the user it was linked to, and a user already in an A/B test keeps the version recorded for them. |
projectId |
Optional for a key bound to a project; naming another project answers 403 project_forbidden. |
expose |
Optional. false reads without recording an A/B exposure; see POST /v1/flags/:key/exposure. |
{ "userId": "u_123", "flags": { "new_onboarding": true, "paywall_v2": false } }Answered with Cache-Control: no-store. Rate limited per key: 300 requests per 15 minutes per userId (per client address without one), and 6000 per 15 minutes per address; past either, 429.
GET /v1/flags/:key
Same auth and query. An unknown key is not an error: { "key": "x", "enabled": false, "exists": false }.
POST /v1/flags/:key/exposure
Same auth, scope and limits. Body { "userId": "..." }. Enters the user in the flag's A/B test with the version the server assigns (partial rollouts only, never hand-picked users, first exposure wins). Answers 200 { "key", "enabled", "exists", "recorded" }; recorded is false when the user was already counted or the flag is not a test, and an unknown key answers exists: false. Use it with expose=false reads when only part of the users belong to the experiment.
Managing flags: /v1/dashboard/flags and /v1/feature-flags
The routes below exist twice with the same bodies and answers: under /v1/dashboard/flags for a signed-in person (any project of their workspaces), and under /v1/feature-flags for a key with flags:manage (its own environment, and only its project when bound to one; projectId may then be left out, it defaults to the key's project).
GET /v1/dashboard/flags?projectId=
Dashboard auth. The flags with their rules (enabled, rolloutPercent, userIds, lastEvaluatedAt).
POST /v1/dashboard/flags
Body: projectId, key, optional description, enabled (default false), rolloutPercent (default 100), userIds. 409 if the project already has the key, 422 limit_reached past 200 flags.
PATCH /v1/dashboard/flags/:id
Any of description, enabled, rolloutPercent, userIds, goalEventType (null clears it). The key cannot change. A changed rolloutPercent stamps rolloutChangedAt. A new share while the flag splits users, or a split switched back on, opens a new phase; stopping the split (100%, 0%, off) sets phaseEndedAt.
GET /v1/dashboard/flags/:id/results
The A/B result. newVersion and oldVersion each carry users, converted and rate; plus relativeLift, probabilityNewBetter, verdict (not_enough_data, new_better, old_better, no_clear_difference), phase (the numbers cover this phase only), startedAt (its first user), endedAt (when the split stopped, goals after it are not counted), isRunning, rolloutChangedDuringTest (deprecated, always false), and goalOptions, the project's event types from the last 30 days. See A/B results.
DELETE /v1/dashboard/flags/:id
204. Code still checking the key reads it as off.
GET /v1/dashboard/flags/prompt?projectId=&flagKey=
The prompt for a coding agent, as { "prompt": "..." }. flagKey adds the task of wiring that one flag.
MCP
POST /mcp
JSON-RPC 2.0 over HTTP, stateless. Authenticated with a lg_mcp_ token as a bearer, or at
POST /mcp/k/:token for clients that accept only a URL. Full detail in
AI Connectors (MCP).
Example Integration Flow
curl -X POST https://api.logga.sh/v1/events \
-H "Authorization: Bearer lg_sk_test_1234567890" \
-H "Content-Type: application/json" \
-d '{
"channel": "auth",
"event": "login_failed",
"level": "warning",
"actor": {
"id": "user_123",
"email": "[email protected]"
},
"metadata": {
"reason": "invalid_password"
}
}'