REST API reference for browser and admin analytics — overview, vitals, flag impact, admin funnels, adoption cohorts, events, variants, funnels, export, and the ingestion endpoints
Last updated October 25, 2026
Analytics API Reference
The query endpoints require authentication and read access to the project whose data they return. Pass your API token via the Authorization: Bearer <token> header. The exceptions are the public showcase's aggregated SDK telemetry (see Public showcase data) and the ingestion endpoints at the end of this page, which have their own rules.
A siteId is a site's ID (site_…, the data-site-id in the script tag) or a project ID. It resolves to the project that owns it, and a site's reads also include the events stored under that project's ID (telemetry sent without a site, and events stored before sites existed). A projectId sent with a siteId never changes whose data is read: it counts only when it is the site's own project. Otherwise access is checked against the project that owns the site, and you get 403 unless you can read that project. siteId=default, the shared bucket of beacons that named no project or site, answers 404 on every analytics route.
The browser-analytics reads leave out the events the ingest marked untrusted: those sent from another origin than the site's domain, or naming none (see Trust marks). That covers every /api/analytics/* read, page-level flag analytics when it answers from stored events, a flag's audience top pages (GET /api/flags/{key}/audience?pages=1), the console Overview's browser vitals (GET /api/projects/{id}/overview, which reports them in vitalsExcluded) and an alert firing's browser impact (impact.browserExcluded in GET /api/projects/{id}/alert-firings/{firingId}). The routes that return totals say how many they left out in excluded: untrustedEvents, and untrustedSessions, the distinct sessions among them. A project member can count those events too by adding includeUntrusted=1 (or true); excluded is then null. Callers who reach a project only through a public project's floor, and anonymous reads of the public showcase, can't: for them the parameter is ignored.
Some rows count whatever their mark:
- Mobile app events (type
mobile_event) are always counted. Apps send noOrigin, so every app event is marked untrusted and the mark can't tell an app's real events from forged ones. - The overview's sessions, bounce rate and session duration count every session, because session rows carry no trust marks.
- SDK evaluation summaries (type
eval_summary, which@flaggr/sdkalso sends from servers, with noOrigin) never count as visitors or as a variant's sessions, nor inexcluded: the overview, flag impact, the flag drilldown and the flight recorder leave them out whatever their mark. The SDK telemetry report counts them in its evaluation counts.
GET /api/analytics/sdk-telemetry leaves out untrusted web vitals, recent errors, flag × vital correlation, rage and dead clicks and component renders, including what a server instance holds in memory before it is stored. It has no excluded field. Site health and rollout guardrails always leave untrusted events out, whatever the caller asks for.
Public showcase data
The landing-page beacons (flaggr-marketing, flaggr-showcase) are readable by everyone only as aggregates. A projectId sent with either of these site IDs doesn't change this: the read is still checked against the showcase project, so access to your own project never opens the raw routes. Session replays (/api/analytics/flight-recorder), raw events (/api/analytics/events), the full SDK telemetry report (recent errors, frustration events), each JS issue's newest event, single pageview render traces and the webhook export need a membership of the showcase project. Signed-in visitors, who reach it through its read-only floor, get 403 SHOWCASE_MEMBERS_ONLY from /api/analytics/events, /api/analytics/flight-recorder, /api/analytics/drilldown?kind=render and POST /api/analytics/export. The other routes give them aggregates (aggregatesOnly: true on vitals and issues):
- Pages are cut at the query string and fragment, and flaggr.dev's console and share-link pages are collapsed to
/console/*and/f/*(the site's beacon covers every page of flaggr.dev). This applies to the vitals' top pages and render pages, the segments, flag RUM and the export download, the drilldowns, the issues' pages andGET /api/projects/{id}/analytics(last-seen times to the hour). Apagefilter or drilldown value matches that level only. - Referrers are given by host (
Directwhen there is none), in the vitals and the referrer drilldown. GET /api/analytics/vitalsreturns no render traces (details.rendering.tracesis empty).GET /api/analytics/issueslabels each issue by kind and fingerprint (Browser error · err_…orUnhandled promise rejection · err_…),nameisnull, and itssamplekeeps only the release fields andhandled.- The SDK telemetry report has
recentErrorsandfrustrationsempty.
Anonymous telemetry reads (GET /api/analytics/sdk-telemetry for those two site IDs) are limited to 60 per minute per IP (429 with Retry-After) and cached for 30 seconds.
Writes: only the landing page's own beacon may write flaggr-marketing or flaggr-showcase without a credential (sent from the app's own origin, naming the site's owning project if it names one). All other SDK telemetry needs the project's SDK key or a project API token (see SDK Telemetry Ingestion), and the public ingest refuses both IDs.
Overview
GET /api/analytics/overview
Returns aggregate metrics for a site over a time range.
Query parameters
| Parameter | Required | Description |
|---|---|---|
siteId | Yes | Site ID (site_…, the data-site-id in the script tag) or project ID |
range | No | Time range: 1h, 24h, 7d or 30d (default 24h). Any other value is answered as 24h; the response's range and comparison.label name the window used. |
includeUntrusted | No | 1 or true: also count the events the ingest marked untrusted (project members only) |
Response
{
"visitors": 1842,
"pageviews": 5231,
"avgSessionDuration": 124500,
"bounceRate": 34,
"excluded": { "untrustedEvents": 12, "untrustedSessions": 3 },
"timeSeries": [
{
"bucket": "2026-10-07T08:00:00.000Z",
"visitors": 151,
"pageviews": 402,
"sessions": 138,
"avgSessionDuration": 118200,
"bounceRate": 31
}
],
"comparison": {
"label": "Previous 24h",
"visitors": 1710,
"pageviews": 4980,
"avgSessionDuration": 121000,
"bounceRate": 36,
"excluded": { "untrustedEvents": 9, "untrustedSessions": 2 },
"timeSeries": []
},
"range": "24h"
}| Field | Type | Description |
|---|---|---|
visitors | number | Unique session IDs among the period's counted events (SDK evaluation summaries aren't visits and never count) |
pageviews | number | Counted page view events |
avgSessionDuration | number | Mean session duration in milliseconds, rounded |
bounceRate | number | Percentage (0–100) of sessions with at most one page |
excluded | object | null | Untrusted events left out of visitors and pageviews (untrustedEvents, untrustedSessions); null with includeUntrusted=1 |
timeSeries | array | Buckets of 5 minutes, 2 hours, 1 day or 3 days (for 1h, 24h, 7d, 30d), each with bucket (ISO start), visitors, pageviews, sessions (sessions that started in the bucket), avgSessionDuration and bounceRate. Empty buckets at either end are trimmed; empty buckets between non-empty ones are kept. |
comparison | object | The same fields for the preceding window of the same length (its own excluded included), with label (Previous <range>) |
range | string | The window used |
Web Vitals
GET /api/analytics/vitals
Returns percentile data for Core Web Vitals, optionally per variant of one flag.
Query parameters
| Parameter | Required | Description |
|---|---|---|
siteId | Yes | Site ID (site_…, the data-site-id in the script tag) or project ID |
range | No | Time range: 1h, 24h, 7d, 30d (default: 24h) |
flagKey | No | Per-variant vitals for this flag (any range but 1h; see below) |
includeUntrusted | No | 1 or true: also count the events the ingest marked untrusted (project members only). The read then takes the raw events, so source is raw_events |
Response
{
"vitals": [
{ "metric": "lcp", "p50": 1200, "p75": 1850, "p95": 3200, "count": 4521 },
{ "metric": "cls", "p50": 0.03, "p75": 0.08, "p95": 0.18, "count": 4521 },
{ "metric": "inp", "p50": 85, "p75": 145, "p95": 320, "count": 3890 },
{ "metric": "ttfb", "p50": 120, "p75": 280, "p95": 650, "count": 4521 },
{ "metric": "fcp", "p50": 900, "p75": 1400, "p95": 2200, "count": 4521 }
],
"timeSeries": [],
"details": {},
"range": "7d",
"source": "hourly_rollup",
"aggregatesOnly": false,
"excluded": { "untrustedEvents": 40, "untrustedSessions": 6 }
}| Field | Type | Description |
|---|---|---|
vitals[].metric | string | Metric name: lcp, cls, inp, ttfb, fcp |
vitals[].p50 | number | 50th percentile value |
vitals[].p75 | number | 75th percentile value |
vitals[].p95 | number | 95th percentile value |
vitals[].count | number | Number of measurements |
timeSeries | array | Hourly p75 per metric (not for 1h) |
details | object | Breakdowns by browser, device, country, page and referrer, rating shares, and render diagnostics (rendering) |
source | string | hourly_rollup or raw_events: ranges other than 1h read the hourly rollup when it has rows for the window, else the raw events; includeUntrusted=1 always reads the raw events. A job recomputes the last 3 hours of the rollup every hour, so a rollup read covers only what the job had rolled up by its last run |
aggregatesOnly | boolean | true for the public showcase's floor callers (see Public showcase data) |
excluded | object | null | Untrusted web-vital and page view events (and their sessions) left out; null with includeUntrusted=1 |
With flagKey, vitals is instead a list of hourly rows from the rollup for events that carried the flag: hour, metric, variant (the flag's value), p50, p75, p95 and count. Each row is one combination of the page's flag values, so a variant can have several rows an hour, and their percentiles can't be combined; for one p75 per variant use GET /api/analytics/flag-rum?flagKey= (see Conversion Rates by Variant). With flagKey the response has only vitals, range, flagKey, aggregatesOnly and excluded (the untrusted web-vital events that carried the flag). The rollup never holds untrusted events, so includeUntrusted doesn't apply.
Flag Impact
GET /api/analytics/flag-impact
Returns per-variant performance metrics for each flag the site's events carried (at most 50 flags), read from each event's flags.
Query parameters
| Parameter | Required | Description |
|---|---|---|
siteId | Yes | Site ID (site_…, the data-site-id in the script tag) or project ID |
range | No | Time range: 1h, 24h, 7d, 30d (default: 24h) |
includeUntrusted | No | 1 or true: also count the events the ingest marked untrusted (project members only) |
Response
{
"impact": [
{
"flagKey": "new-checkout",
"variants": [
{ "variant": "control", "pageviews": 2100, "sessions": 820, "lcpP75": 1900, "avgCls": 0.05 },
{ "variant": "v2", "pageviews": 2050, "sessions": 810, "lcpP75": 1650, "avgCls": 0.04 }
]
}
],
"range": "24h",
"excluded": { "untrustedEvents": 25, "untrustedSessions": 4 }
}| Field | Type | Description |
|---|---|---|
impact[].flagKey | string | Feature flag key |
impact[].variants[].variant | string | Variant name or value |
impact[].variants[].pageviews | number | Page views for this variant |
impact[].variants[].sessions | number | Unique sessions for this variant (SDK evaluation summaries don't count) |
impact[].variants[].lcpP75 | number | null | LCP 75th percentile (ms); null without LCP samples |
impact[].variants[].avgCls | number | null | Average CLS score; null without CLS samples, and also when the average is exactly 0 |
excluded | object | null | Untrusted events that carried flags (and their sessions) left out; null with includeUntrusted=1 |
Admin Funnel
GET /api/analytics/admin-funnel
Returns first-party Flaggr console activation metrics for a project.
Query parameters
| Parameter | Required | Description |
|---|---|---|
projectId | Yes | Project ID |
projectSlug | No | Ignored (deprecated). Admin events are matched by project id or by the project's stored slug. |
range | No | Time range: 24h, 7d, 30d (default: 7d) |
Response
{
"projectId": "proj_abc123",
"range": "7d",
"funnel": {
"steps": [
{ "event": "screen_viewed", "users": 120, "conversionRate": 1, "dropOffRate": 0 },
{ "event": "flag_viewed", "users": 86, "conversionRate": 0.72, "dropOffRate": 0.28 },
{ "event": "activation", "users": 51, "conversionRate": 0.43, "dropOffRate": 0.41 }
]
},
"experimentCycleTime": {
"sampleSize": 8,
"medianHours": 18.5
}
}| Field | Type | Description |
|---|---|---|
funnel.steps[].event | string | Funnel step: screen_viewed, flag_viewed, or activation |
funnel.steps[].users | number | Actors who reached the step |
funnel.steps[].conversionRate | number | Share of entry actors who reached the step |
funnel.steps[].dropOffRate | number | Share that dropped from the previous step |
experimentCycleTime.sampleSize | number | Experiments with both start and decision events |
experimentCycleTime.medianHours | number | null | Median hours from experiment_started to experiment_decision |
Caching: Cache-Control: private, max-age=30
Adoption Cohorts
GET /api/analytics/adoption-cohorts
Returns weekly create-to-view adoption cohorts for a project.
Query parameters
| Parameter | Required | Description |
|---|---|---|
projectId | Yes | Project ID |
projectSlug | No | Ignored (deprecated). Admin events are matched by project id or by the project's stored slug. |
range | No | Time range: 7d, 30d, 90d (default: 30d) |
Response
{
"projectId": "proj_abc123",
"range": "30d",
"cohorts": [
{
"week": "2026-05-11T00:00:00.000Z",
"createdProjects": 12,
"adoptedProjects": 9,
"adoptionRate": 0.75
}
]
}| Field | Type | Description |
|---|---|---|
cohorts[].week | string | ISO timestamp for the start of the cohort week |
cohorts[].createdProjects | number | Project-week cohorts with created flags |
cohorts[].adoptedProjects | number | Project-week cohorts with matching flag views |
cohorts[].adoptionRate | number | adoptedProjects / createdProjects |
Caching: Cache-Control: private, max-age=60
Events
GET /api/analytics/events
Returns the most recent raw events, without the ones the ingest marked untrusted unless a project member adds includeUntrusted=1 (the feed has no time window, so it reports no excluded count). For the public showcase's site ids, only members of the showcase project may read them; callers who reach it through its public floor get 403 SHOWCASE_MEMBERS_ONLY (see Public showcase data).
Query parameters
| Parameter | Required | Description |
|---|---|---|
siteId | Yes | Site ID (site_…, the data-site-id in the script tag) or project ID |
limit | No | Number of events (max 100, default: 50) |
includeUntrusted | No | 1 or true: include the events the ingest marked untrusted (project members only) |
Response
{
"events": [
{
"id": 12345,
"type": "webvital",
"url": "https://example.com/checkout",
"timestamp": "2026-03-20T10:30:00.000Z",
"country": "US",
"browser": "Chrome",
"flags": { "new-checkout": "v2" },
"data": { "metric": "lcp", "value": 1650, "rating": "good", "_src": "worker", "_c": "3f9a1c0b7e42" }
}
]
}| Field | Type | Description |
|---|---|---|
events[].type | string | Event type. The browser script sends pageview, webvital, click, error, scroll and custom; SDK telemetry adds types such as conversion, ux_frustration and eval_summary, and mobile batches are mobile_event |
events[].url | string | Page URL where the event occurred, scrubbed by the ingest |
events[].timestamp | string | When the event happened (never later than when it was received) |
events[].country, events[].browser | string | Added by the ingest |
events[].flags | object | Active flag variants at event time |
events[].data | object | Event-specific payload (varies by type), plus the ingest's trust marks _src, _c and, on untrusted events, _untrusted (Trust marks). Untrusted events other than mobile events appear only with includeUntrusted=1 |
Caching: Cache-Control: private, max-age=5
Flag Variant Statistics
GET /api/analytics/flags/{key}/variants
Returns detailed per-variant analytics for a specific flag, including evaluation counts, conversion rates, and latency. It doesn't read browser analytics. Evaluations are the ones the Flaggr control plane (https://flaggr.dev) served and recorded, and the exposure events your code reports to POST /api/events/exposures/batch; evaluations served by https://api.flaggr.dev or evaluated locally in an SDK aren't recorded. Conversions are the outcome events stored by POST /api/events/outcomes, which needs a credential authorized like flag evaluation (the project's SDK key or a project API token). https://flaggr.dev serves both event routes, and https://api.flaggr.dev forwards them there.
Query parameters
| Parameter | Required | Description |
|---|---|---|
projectId | Yes | Project ID |
environment | No | Environment name (default production) |
period | No | Time period: 1h, 24h, 7d, 30d (default: 24h) |
serviceId | No | One service of the project. Without it, the statistics cover only the project's services the caller may read in environment (member access overrides and credential scopes apply; 403 when the caller's access to the environment is none; an empty result when no service is readable). |
eventName | No | Custom event name for conversion tracking |
Response
{
"flagKey": "new-checkout",
"serviceId": "svc-web",
"environment": "production",
"period": "7d",
"variants": [
{
"variant": "control",
"evaluations": 12500,
"conversions": 625,
"conversionRate": 0.05,
"avgEventValue": 42.50,
"latencyP50": 3.2,
"latencyP99": 18.5
}
],
"totalEvaluations": 24800,
"totalConversions": 1363,
"overallConversionRate": 0.055
}serviceId is empty when the request named no service.
Caching: Cache-Control: private, max-age=30
Funnel Analysis
GET /api/analytics/funnel
Analyze multi-step conversion funnels segmented by flag variant. The steps are outcome events (POST /api/events/outcomes, see Flag Variant Statistics), and users are counted by the userId of their events, so events without one don't count. A user reaches a step when they have an event for it and for every step before it, and counts toward the variant of their first event.
Query parameters
| Parameter | Required | Description |
|---|---|---|
projectId | Yes | Project ID |
flagKey | Yes | Flag key to segment by |
steps | Yes | Comma-separated event names defining the funnel (2 to 10) |
environment | No | Environment name (default production) |
period | No | 1h, 24h, 7d or 30d (default: 7d) |
serviceId | No | Filter to a specific service |
Response
{
"projectId": "proj_abc123",
"flagKey": "new-checkout",
"steps": ["page_view", "add_to_cart", "checkout", "purchase"],
"environment": "production",
"period": "7d",
"funnelSteps": [
{
"step": "page_view",
"stepIndex": 0,
"totalUsers": 5000,
"byVariant": { "control": 2500, "v2": 2500 },
"dropOffRate": 0
},
{
"step": "purchase",
"stepIndex": 3,
"totalUsers": 600,
"byVariant": { "control": 250, "v2": 350 },
"dropOffRate": 0.25
}
],
"variants": ["control", "v2"],
"overallConversionRate": 0.12
}| Field | Type | Description |
|---|---|---|
funnelSteps[].step | string | Event/step name |
funnelSteps[].totalUsers | number | Users who reached this step |
funnelSteps[].byVariant | object | User count per flag variant |
funnelSteps[].dropOffRate | number | Fraction that dropped off from previous step |
Caching: Cache-Control: private, max-age=30
Page-Level Flag Analytics
GET /api/projects/{id}/analytics
Returns which flags are evaluated on which pages: per page, each flag's evaluation count, variant counts and average latency, plus the top flags and totals (pages, topFlags, totalEvaluations, totalPages, totalFlags).
Query parameters
| Parameter | Required | Description |
|---|---|---|
serviceId | No | One service of the project |
includeUntrusted | No | 1 or true: also count the page views the ingest marked untrusted (project members only) |
The answer comes from the server instance's in-memory record of exposure events that named a page URL (and of demo data). When that has none and no serviceId is given, it comes from the project's stored page views that carried flags, over the last 30 days: the ones stored under the project's own ID, such as those the SDK's telemetry plugin sends. Those leave out the page views the ingest marked untrusted (for example a telemetry batch sent with the project's public SDK key from another origin) and report them in excluded (untrustedEvents, untrustedSessions; null with includeUntrusted=1). An answer from the in-memory record has no excluded: those entries carry no trust marks.
Caching: Cache-Control: private, no-cache, must-revalidate
Export
POST /api/analytics/export
Export evaluation metrics and outcome events to a webhook endpoint.
Request body
{
"projectId": "proj_abc123",
"destination": "webhook",
"webhookUrl": "https://example.com/analytics-hook",
"signingSecret": "whsec_...",
"dataTypes": ["evaluations", "outcomes"],
"period": "24h"
}| Field | Required | Description |
|---|---|---|
projectId | Yes | Project ID |
destination | Yes | webhook, the only destination |
webhookUrl | Yes | HTTPS URL on a public address to POST exported data to. http, localhost, and loopback, private, link-local or metadata addresses get 400. A host that resolves to such an address when the export runs gets nothing, and redirects aren't followed |
signingSecret | No | HMAC-SHA256 signing secret for webhook verification |
dataTypes | No | Data types to export (default: all) |
period | No | Time period to export: 1h, 24h, 7d or 30d (default: 24h) |
Exporting needs a membership of the project. Callers who reach the shared demo or the showcase only through its public floor get 403 (MEMBERS_ONLY on the demo, SHOWCASE_MEMBERS_ONLY on the showcase).
Response
{
"ok": true,
"exported": {
"evaluations": 24800,
"outcomes": 1363
}
}Ingestion Endpoint
POST https://ingest.flaggr.dev/e
This is the Cloudflare Worker endpoint that receives events from the browser analytics script. You generally don't call this directly — the script handles it. It takes no credentials: site IDs are in every page, so it checks the site ID, rate limits and marks each event with what it can vouch for (trust marks).
Request
- Body: a JSON array of up to 100 events and 100 KB, all for one site.
- Content-Type: any. The script's
fetchwhile the page is open sendsapplication/json. At page hide it sends the same JSON astext/plainin akeepalivefetch(withnavigator.sendBeaconwherefetchhas nokeepalive, or where nothing more fits the 64 KBkeepaliveallowance). The two are written differently (see Response). - Site ID: the ID Flaggr issued for the site (
site_…). Any other ID (a slug, a UUID, a project ID,defaultor the public showcase IDs) gets403. - User-Agent: a browser's. Headless Chrome, Firefox and WebKit pass. From anything else (crawlers, HTTP clients, an empty header) every event is counted as rejected and nothing is stored.
| Event field | Rule |
|---|---|
type | pageview, webvital, click, error, scroll or custom. Conversions are custom events with data.conversion: true. |
siteId | The same site ID in every event of the batch |
sessionId | A non-empty string of at most 128 characters |
timestamp | Epoch milliseconds, no more than 2 hours old and no more than 24 hours in the future. A future-dated event is stored at the time ingest received it. |
url | A string. URLs longer than 4,096 characters are cut, after scrubbing. |
flags | An object (any other value is stored as null) |
data | An object, or absent |
An event nested more than 32 levels deep (the event itself counting as one) is invalid. Invalid events are dropped and counted in rejected; the rest of the batch is still stored. Typed data fields that don't fit their type are dropped. At most 20 error events per batch are stored, and each site gets 10 batches with error events per 10 seconds per Cloudflare location; past that each tab still stores each distinct error once a minute (up to 3 kinds of error a batch), and the error events past the budget count as rejected.
Before storage, every URL the ingest keeps is scrubbed: the event's url, and in data the path, referrer, url, href and source, each renderResources[].url and .label, and each renderBackendRequests[].path. The fragment is dropped. The value of a query parameter, or of a path parameter (;name=value), becomes redacted when its name contains token, secret, passw, signature, credential, apikey, accesskey, privatekey, jwt, bearer, sessionid, sessid, saml, relaystate, assertion, verifier, nonce, ticket, oobcode, authcode, authorization, csrf, xsrf, hmac or email, or has one of the words code, key, auth, state, session, sid, sig, otp, totp, mfa, 2fa, pin, pass, pwd, pw or creds, or when its value holds an email address or a JWT. Names are split into words at camelCase and punctuation and compared in lower case, so accessToken, access_token and Access-Token are the same, while keyword, postcode and sessions are kept. A path segment that is a JWT, and user information (user:password@), become redacted; mailto:, tel:, sms: and callto: links keep only their scheme. In data.title, text, message and stack, every absolute http(s) URL is scrubbed the same way and email addresses become redacted. Not scrubbed: a token or an email address that is a plain path segment of one of those URLs (/reset/<token>, /unsubscribe/jane@example.com), a token inside a URL nested in a parameter (?next=%2Finvite%3Ftoken%3D…), a JWT in text outside a URL, URLs inside text that are relative or not http(s) (wss://…), custom event properties and flag values, so keep personal data out of those. Keys named _src, _untrusted or _c in an event's data are dropped, and the ingest adds its own.
Response
{
"accepted": 18,
"rejected": 2
}| Status | When |
|---|---|
202 | { "accepted", "rejected" }. For an application/json post the accepted events are stored before the answer; other content types (the script's page-hide sends) are accepted and written in the background, with retries. Also when no event was valid (accepted: 0, nothing written). |
400 | The body isn't valid JSON or a JSON array, or the batch has more than 100 events |
403 | {"error": "Invalid site_id"}: the site ID is missing, malformed, unknown, reserved or blocked. {"error": "Mixed site_id batch"}: the batch names more than one site. The script stops sending from the page. |
413 | The body is over 100 KB |
422 | {"error": "Batch could not be stored"}: the database refused the rows as data. A retry would fail the same way, so the script drops the batch. |
429 | Over a rate limit (see Rate Limits), with Retry-After: 60 |
503 | Storage or the site check is temporarily unavailable, with Retry-After (5 to 60 seconds). The batch isn't stored; the script keeps it and retries. |
Responses carry X-Events-Accepted and X-Events-Rejected on 202. Every response, the OPTIONS preflight included, sends Access-Control-Allow-Origin: * and lists Retry-After, X-Events-Accepted and X-Events-Rejected in Access-Control-Expose-Headers, so a page script on any origin can read them. OPTIONS answers 204, other methods 405, and other paths 404.
SDK Telemetry Ingestion
POST https://flaggr.dev/api/analytics/sdk-telemetry
Receives telemetry from @flaggr/sdk's telemetry plugin (flaggrTelemetry(), or data-telemetry on the SDK script tag): evaluation summaries, Web Vitals and errors, each with the flag values in use. The plugin names no site, so its rows are stored under the project's ID. You generally don't call this endpoint directly.
https://flaggr.dev serves this route, and https://api.flaggr.dev forwards it there. @flaggr/sdk 0.5.0 and later post to https://flaggr.dev when the client's apiUrl is https://api.flaggr.dev (the default), and flaggrTelemetry({ endpoint }) points the plugin elsewhere, such as a self-hosted Flaggr app. @flaggr/sdk 0.4.0 posts to the client's apiUrl, which reaches the route on either host.
- Body: JSON whatever the
Content-Type(a page-hide flush goes astext/plain), at most 2 MiB. A bigger body gets413 {"error": "Payload too large"}before its credential is checked, whether itsContent-Lengthsays so or it streams past 2 MiB without one (the rest isn't read). A body that can't be read gets400. - Authentication: the project's SDK key or a project API token, as
Authorization: Bearer <key>or asapiKeyin the JSON body (a request sent as the page closes can't carry the header without a CORS preflight).@flaggr/sdk0.4.0 sends its page-hide flush without the key, so that flush is never stored; 0.5.0 puts the key in the body. Without anAuthorizationheader, anapiKeythat isn't printable ASCII without spaces (no key is anything else) gets401 TELEMETRY_AUTH_INVALID. Personal access tokens get403 TELEMETRY_CREDENTIAL_NOT_ALLOWED; console sessions, connector tokens and unknown or revoked tokens get401 TELEMETRY_AUTH_INVALID. A batch without a credential gets401 TELEMETRY_AUTH_REQUIRED, except Flaggr's own landing-page beacon (see Public showcase data). - Scope: the batch may name only the credential's project (
projectId), one of its services (serviceId) and one of its sites (siteId); anything else is403 TELEMETRY_SCOPE_MISMATCH. That includes asiteIdorserviceIdholding U+0000 or an unpaired surrogate, which names no site or service. A key scoped to services, environments or flags rather than its whole project is checked first, against the batch's service: the service of the named site when the site is one of the project's, else theserviceIdthe batch names. When its scopes don't cover that service, or the batch has none, it gets403 TELEMETRY_FORBIDDENinstead of the refusals above; a key scoped only to environments or flags covers no telemetry, since a batch names neither. A credential refused for another reason, such as an expired key, also gets403 TELEMETRY_FORBIDDEN. Rows are stored under the site's ID, else the project's. - Trust: an SDK key ships in the page, so it says nothing about where a batch came from. Rows are marked
_untrustedunless the request'sOrigin(orReferer, without one) is the named site's domain or a subdomain of it; for a batch that names no site, the domain of any of the project's sites. Server-side telemetry sends noOrigin, so its rows are always untrusted: rollout guardrails and the analytics reads ignore them, but the telemetry report still counts their evaluation summaries. - Scrubbing: before storage, rows are scrubbed by the public ingest's rules and more. URLs (the row's URL,
pageUrl, the session's first URL and referrer, thedataURL fields, resource URLs and labels, backend paths) also lose path segments that hold an email address or a JWT, and a URL nested in a parameter (a sign-in page'snext) is scrubbed too, up to 3 deep. Text (message,stack,title,text,elementText, breadcrumb messages) also loses JWTs, the credentials in URLs of any scheme (wss://, an app's deep link) and the credential-like parameters of relative URLs and fragments. Where the SDK cut a value (an LCP or resource URL at 500 characters, a resource label at 120, a backend path at 200, element text at 40) and its end could be the start of an email address or a JWT, that end is stored asredacted. URLs are then cut to 4,096 characters. An entry nested more than 32 levels deep (the entry counting as one) is skipped and not counted inprocessed. - Responses:
200 {"success": true, "processed": {…}}when the batch is accepted; its rows are written after the response.503 TELEMETRY_CHECK_UNAVAILABLEwhen the credential or site couldn't be checked (nothing stored; retry).
Mobile Event Ingestion
POST https://flaggr.dev/api/ingest/mobile-events
Receives safelabel-mobile app events: a JSON array of at most 100 events and 256 KB, all naming one siteId. Each event has source: "safelabel-mobile", siteId, sessionId, installId, event (one of the app's known event names), occurredAt (ISO time, no more than 2 hours old or 60 seconds ahead), appVersion, buildNumber, appVariant, platform, apiOrigin, and optional userId and properties. Rows are stored with type mobile_event. Two ways to send:
- With a credential: the project's SDK key or a project API token (
Authorization: Bearer <key>).siteIdmay be the project's ID or one of its sites. Rows getdata._src = "sdk". - Without one:
siteIdmust be a site's own ID. Project IDs,defaultand the public showcase IDs are refused. Rows getdata._src = "mobile".
Either way rows get data._untrusted = true unless the request's Origin (or Referer, when there is no Origin) is the site's domain or a subdomain of it (for a project ID sent with a key, one of the project's site domains). Apps send no Origin, so their rows are untrusted: analytics views still count them (mobile events are never left out as untrusted), but rollout guardrails ignore them.
Rows are scrubbed before they are stored, with the rules SDK telemetry uses for URLs and text: the event's URL (and so the session's first URL) and apiOrigin as URLs, and every string in properties, nested ones included. A property named path, url, referrer, href, source or route is scrubbed as a URL when it starts with /, ?, a scheme and ://, or mailto:, tel:, sms: or callto:; any other string is scrubbed as text. A userId that holds an email address or a JWT is stored as null, so send an opaque ID. An event that can't be scrubbed, or that is nested more than 32 levels deep, is rejected (counted in rejected); the rest of the batch is stored.
| Status | When |
|---|---|
202 | {"accepted", "rejected", "siteId"}: the accepted events are stored |
400 | Invalid JSON, an empty batch, or more than one siteId |
401 | MOBILE_AUTH_REQUIRED (no credential, and siteId isn't a site's own ID) or MOBILE_AUTH_INVALID (a credential that isn't an SDK key or project API token) |
403 | MOBILE_CREDENTIAL_NOT_ALLOWED (a personal access token), MOBILE_SCOPE_MISMATCH (siteId isn't the key's project or one of its sites) or MOBILE_FORBIDDEN (for example, an expired key) |
413 | More than 100 events, or a body over 256 KB |
429 | Rate limited (per address) |
500 | The write failed |
503 | MOBILE_CHECK_UNAVAILABLE, with Retry-After: 30; or Analytics not configured |
The public ingest also takes mobile batches at POST https://ingest.flaggr.dev/ingest/mobile-events, with no credentials: site IDs only (older slug and UUID site IDs too), written in the background, and rows marked _src: "worker" and untrusted. It scrubs them as this route does, and rejects only the event it can't scrub, not the batch.
Server Telemetry Ingestion
POST https://flaggr.dev/api/ingest/server-events
Receives backend telemetry from your own server-side services — errors and request spans, the in-house Sentry replacement (nothing in Flaggr receives OTLP, so services report over REST). The first sender is the psymatch Go GraphQL API; anything that can hold a project API token can report. Rows land in server_telemetry_events and are read back through GET /api/analytics/server-events and the MCP tools list_server_events / get_server_event_summary.
- Body:
{ "events": [...] }, at most 100 events and 256 KB. A bigger body gets413 {"error": "Payload too large"}before its credential is checked, whether itsContent-Lengthsays so or it streams past 256 KB without one; a batch over 100 events gets413 {"error": "Batch too large"}. - Authentication: the project's API token (
Authorization: Bearer <key>) with a project-level scope (a legacy token without stored scopes needs the read permission), or a project-scoped Flaggr JWT. A token whose scopes name only services, environments or flags gets403 SERVER_EVENTS_FORBIDDEN, because the route names no service. Unlike the mobile and SDK telemetry routes, SDK keys and demo tokens are refused (403 SERVER_EVENTS_FORBIDDEN): they ship inside client bundles, and server telemetry rows carry no untrusted mark a read could leave out. Personal access tokens get403 SERVER_EVENTS_CREDENTIAL_NOT_ALLOWED; sessions, connector tokens and unknown or revoked tokens get401 SERVER_EVENTS_AUTH_INVALID. - The token decides the project. Rows are always stored under the token's project — the payload can't name or fold in another one. An event's
serviceIdis stored only when it is one of that project's services; an event naming another project's service (or an unknown one) is rejected, counted inrejected. - Event fields:
kind("error"or"span", required),timestamp(ISO, required; more than 24 hours ahead is rejected), and optionalserviceId,environment,fingerprint,name,message,stack,route,method,statusCode(integer),durationMs(integer),releaseanddata(an object). An error sent without afingerprintgets one from its stack (its type and the function and file of its innermost in-app frames, without line numbers), or from its type and normalized message when it has no stack: that is how errors group into issues. Strings cap at 2,000 characters formessageandstack, 512 for the rest; over-cap or wrongly typed fields reject the event, never truncate it. An event that fails any check is counted inrejected; the rest of the batch is stored. - Scrubbing:
messageandstackare scrubbed as free text,routeas a URL or text, and every string ofdata, as the console events are: fragments dropped, credential-like parameter values, email addresses and JWTs redacted (see SDK telemetry). - Span sampling: spans are always downsampled. A span is kept when a hash of its project and trace id (
data.traceIdordata.trace_id) falls under the server's rate (10% by default,SERVER_TELEMETRY_SPAN_SAMPLE_RATEfrom 0 to 1), so a trace's spans are kept or dropped together; a span without a trace id is drawn at random. Failed spans (statusCode500 or more, ordata.error: true) are always kept. Each kept span storesdata._sampleRate, the rate it survived: the sender's owndata.sampleRate(0 to 1, if it head-samples) times the server's, or the sender's alone for a failed span. A_sampleRatethe sender sends is overwritten. Errors are never sampled. - Storage quotas: after sampling, events are counted against per-project quotas, and an event over one is dropped and counted in
throttled: 30 errors per minute per fingerprint (the sender's, or the one the ingest gives it), so a crash loop can't crowd out other errors; 600 errors and 2,000 spans per minute per project; 500,000 events per day per project. - Batch limit: 120 batches per minute per token. Over it,
429 SERVER_EVENTS_RATE_LIMITEDwithRetry-After; nothing is stored, so a sender should wait and resend. - Retention: spans are kept 7 days and errors 30; the hourly analytics job prunes older rows.
- Responses:
202 {"accepted", "rejected", "sampledOut", "throttled"}. The answer comes before the rows are written (written in the background after the response), soacceptedcounts the events that passed validation, sampling and the quotas, not the rows the database took.503 SERVER_EVENTS_CHECK_UNAVAILABLE(withRetry-After: 30) when the credential or a service lookup couldn't run; nothing is stored.500only when nothing valid remained to schedule.
Read the events back with a project reader's credential (dashboard session, connector or project API token):
GET /api/analytics/server-events?projectId=…&kind=error&hours=24&limit=50&fingerprint=…
kind (error/span), hours (1–720, default 24), limit (1–200, default 50) and fingerprint are optional; rows come newest first. GET /api/analytics/server-events?projectId=…&summary=1&hours=48 answers the window aggregates instead: the error and span counts, estimatedSpans (the stored spans weighted by 1 / _sampleRate: about how many the services sent), the top error fingerprints, and the span count with p50/p95 durations.
Admin Event Ingestion
POST /ingest/admin-events
This endpoint receives first-party console analytics from the Flaggr admin web app. You generally do not call it directly; the console instrumentation batches events automatically.
Request
- Authentication: only the Flaggr console's signed-in session, sent as
Authorization: Bearer <Firebase ID token>. Without a credential the endpoint returns401 {"code":"ADMIN_EVENTS_AUTH_REQUIRED"}. API tokens, SDK keys, personal access tokens and connector tokens get403 {"code":"ADMIN_EVENTS_SESSION_ONLY"}. Requests are rate limited per address (429). - Project fields are set by the server: an event that names a project (
properties.project_id, elseproperties.project_slug) is stored with that project's ID and slug only when the session can read the project; otherwise the event is dropped. Events that name no project are stored without either field. At most 10 distinct projects per batch; events naming more are dropped. - Content-Type:
application/json - Body: JSON array of admin event objects: at most 100 events and 256 KB per batch, else
413whether or not the request declares its length; every event must name the samesiteId, else400 - Allowed site IDs:
flaggr_admin_prod,flaggr_admin_preview,flaggr_admin_dev, or the configuredNEXT_PUBLIC_FLAGGR_ANALYTICS_SITE_ID/FLAGGR_ANALYTICS_SITE_ID - Timestamp window:
occurredAtmust be no more than 2 hours old and no more than 60 seconds in the future - Scrubbing: rows are scrubbed as mobile events are. The console's
routeproperty (its path and query) loses its fragment and has credential-like query values redacted, including those inside a nestednext=URL. AuserIdthat holds an email address or a JWT is stored asnull. An event nested more than 32 levels deep is rejected
[
{
"source": "flaggr-admin",
"siteId": "flaggr_admin_preview",
"sessionId": "session_abc",
"installId": "install_abc",
"userId": "user_123",
"event": "flag_viewed",
"occurredAt": "2026-05-17T02:30:00.000Z",
"appVersion": "admin-web",
"buildNumber": "development",
"appVariant": "development",
"platform": "web",
"apiOrigin": "http://127.0.0.1:3000",
"properties": {
"project_id": "proj_abc123",
"project_slug": "acme",
"flag_key": "checkout-flow"
}
}
]Response
{
"accepted": 1,
"rejected": 0,
"siteId": "flaggr_admin_preview"
}Status: 202 Accepted. accepted counts the events queued. The project checks and the write run after the 202, so events naming a project the session can't read are dropped then and aren't counted in rejected.
Feature Flag Evaluation Analytics & Post-Toggle Safety
In addition to browser analytics, Flaggr provides high-precision server-side evaluation analytics and automated post-toggle safety diagnostics.
Post-Toggle Safety & Drift Analysis
GET /api/flags/{key}/toggle-impact
Calculates pre-change vs post-change metrics around the most recent flag toggle event, classifying the transition as healthy, warning, degraded, or insufficient_data.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
serviceId | Yes | Service identifier |
environment | No | Target environment (production, staging, development) |
range | No | Evaluation discovery range: 1h, 24h, 7d, 30d (default: 24h) |
windowMinutes | No | Duration window in minutes before and after the toggle |
Response Fields
| Field | Type | Description |
|---|---|---|
impact.hasRecentToggle | boolean | true if at least one toggle event was found in the audit history |
impact.healthStatus | string | Health classification: healthy, warning, degraded, insufficient_data, or none |
impact.latestToggle | object | Timestamp, actor email/ID, transition (from -> to), duration in ms |
impact.before | object | Evaluations, errors, error rate, avg latency, and P99 latency before toggle |
impact.after | object | Evaluations, errors, error rate, avg latency, and P99 latency after toggle |
impact.delta | object | errorRateDelta (+/- rate shift), latencyP99DeltaMs (latency change), evaluationsRatio |
impact.recommendation | string | Automated operational advice (e.g. recommend immediate rollback on degradation) |
recentToggles | array | Recent audit log toggle transitions with metadata |
Flag Evaluation Metrics & Time Series
GET /api/flags/{key}/metrics
Returns total volume, realtime rates (evals/sec, evals/min), latency percentiles (P50, P95, P99), reason breakdown (TARGETING_MATCH, DEFAULT_VALUE, ERROR), value distribution, time series buckets, and toggle impact.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
serviceId | Yes | Service identifier |
environment | No | Filter by environment |
range | No | 1h, 24h, 7d, 30d |
Project Evaluation Heatmap
GET /api/projects/{id}/evaluation-heatmap
Returns an evaluation density heatmap across all services and feature flags in a project, highlighting zero-traffic flags and hotspot workloads.
Query Parameters
| Parameter | Required | Description |
|---|---|---|
range | No | 15m, 1h, 24h, 7d (default 1h) |
serviceId | No | Filter to specific service |
environment | No | Filter to specific environment |
Project Aggregated Health
GET /api/projects/{id}/health
Returns platform-level aggregated health score (0–100), per-service status, top degradation issues, and total evaluation traffic.
Accessing Analytics via Model Context Protocol (MCP)
@flaggr/mcp-server wraps many of these endpoints as tools for AI coding assistants and agents, among them:
| MCP Tool | Description |
|---|---|
get_browser_analytics_overview | A site's visitors, page views, session duration and bounce rate against the previous window (/api/analytics/overview) |
get_web_vitals | A site's Web Vitals percentiles, breakdowns and render diagnostics (/api/analytics/vitals) |
list_server_events | Backend errors and request spans a project's services reported (newest first, filters for kind, fingerprint and window) |
get_server_event_summary | Window aggregates of that server telemetry: error/span counts, top error fingerprints, span p50/p95 durations |
get_flag_toggle_impact | Post-toggle health, drift metrics, before/after table, and rollback recommendations |
get_flag_metrics | Volume, rates, percentiles, reason breakdown, and time series |
get_evaluation_heatmap | Evaluation usage grid across all flag x service x environment cells |
get_project_health | Platform-wide health score, service breakdown, and active issues |
get_browser_analytics_overview and get_web_vitals are on the hosted MCP endpoint (https://flaggr.dev/mcp) and in @flaggr/mcp-server 0.3.0 and later (npm view @flaggr/mcp-server version shows the newest release). They read the same figures as the console, without the events the ingest marked untrusted. There too, get_project_overview and get_incident print how many web-vital or error events from other origins they left out.
Agents can also read the dynamic MCP resource:
flaggr://flag/{key}/toggle-impact
And trigger the safety prompt:
verify_toggle_safety
Rate Limits
The ingestion endpoint (https://ingest.flaggr.dev/e) allows each browser tab (session ID) 60 batches a minute per site, each address (IPv4 address, or IPv6 /64) 1,200 batches a minute per site, and each address 2,400 requests a minute in total. Requests for a site ID the edge hasn't cached also count against a lookup limit of 300 a minute per address, and per IPv6 /48. This bounds database lookups for made-up site IDs; real traffic is almost always answered from the edge's cache. Over a limit the endpoint answers 429 with Retry-After: 60, which the analytics script waits out. The counters are per Cloudflare location and approximate.
Error events have a budget: at most 20 per batch are stored, and each site gets 10 batches with error events per 10 seconds per location. Past that, each tab still stores each distinct error once a minute, so error occurrences are capped but every erroring session is still counted (the js_error_rate guardrail, site health, issues). Error events past the budget are counted as rejected and not stored; the batch's other events are still stored.
/api/ingest/mobile-events and /ingest/admin-events are rate limited per address. /api/ingest/server-events checks both the per-address and the per-token limits. API query endpoints follow the standard Flaggr rate limits documented in Rate Limiting. Anonymous reads of the public showcase's SDK telemetry are limited to 60 per minute per IP (see Public showcase data).
Next Steps
- Flag Health Guide — Automated health scoring and drift detection
- MCP Server Guide — Using Flaggr with Claude, Cursor, and AI agents
- Browser Analytics — Install and configure the script
- Analytics Dashboard — Visual guide to the dashboard
- Admin Analytics — Console activation funnels and adoption cohorts
- Analytics & A/B Testing — Measure experiments with analytics