Skip to main content

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 no Origin, 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/sdk also sends from servers, with no Origin) never count as visitors or as a variant's sessions, nor in excluded: 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 and GET /api/projects/{id}/analytics (last-seen times to the hour). A page filter or drilldown value matches that level only.
  • Referrers are given by host (Direct when there is none), in the vitals and the referrer drilldown.
  • GET /api/analytics/vitals returns no render traces (details.rendering.traces is empty).
  • GET /api/analytics/issues labels each issue by kind and fingerprint (Browser error · err_… or Unhandled promise rejection · err_…), name is null, and its sample keeps only the release fields and handled.
  • The SDK telemetry report has recentErrors and frustrations empty.

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

ParameterRequiredDescription
siteIdYesSite ID (site_…, the data-site-id in the script tag) or project ID
rangeNoTime 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.
includeUntrustedNo1 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"
}
FieldTypeDescription
visitorsnumberUnique session IDs among the period's counted events (SDK evaluation summaries aren't visits and never count)
pageviewsnumberCounted page view events
avgSessionDurationnumberMean session duration in milliseconds, rounded
bounceRatenumberPercentage (0–100) of sessions with at most one page
excludedobject | nullUntrusted events left out of visitors and pageviews (untrustedEvents, untrustedSessions); null with includeUntrusted=1
timeSeriesarrayBuckets 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.
comparisonobjectThe same fields for the preceding window of the same length (its own excluded included), with label (Previous <range>)
rangestringThe window used

Web Vitals

GET /api/analytics/vitals

Returns percentile data for Core Web Vitals, optionally per variant of one flag.

Query parameters

ParameterRequiredDescription
siteIdYesSite ID (site_…, the data-site-id in the script tag) or project ID
rangeNoTime range: 1h, 24h, 7d, 30d (default: 24h)
flagKeyNoPer-variant vitals for this flag (any range but 1h; see below)
includeUntrustedNo1 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 }
}
FieldTypeDescription
vitals[].metricstringMetric name: lcp, cls, inp, ttfb, fcp
vitals[].p50number50th percentile value
vitals[].p75number75th percentile value
vitals[].p95number95th percentile value
vitals[].countnumberNumber of measurements
timeSeriesarrayHourly p75 per metric (not for 1h)
detailsobjectBreakdowns by browser, device, country, page and referrer, rating shares, and render diagnostics (rendering)
sourcestringhourly_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
aggregatesOnlybooleantrue for the public showcase's floor callers (see Public showcase data)
excludedobject | nullUntrusted 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

ParameterRequiredDescription
siteIdYesSite ID (site_…, the data-site-id in the script tag) or project ID
rangeNoTime range: 1h, 24h, 7d, 30d (default: 24h)
includeUntrustedNo1 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 }
}
FieldTypeDescription
impact[].flagKeystringFeature flag key
impact[].variants[].variantstringVariant name or value
impact[].variants[].pageviewsnumberPage views for this variant
impact[].variants[].sessionsnumberUnique sessions for this variant (SDK evaluation summaries don't count)
impact[].variants[].lcpP75number | nullLCP 75th percentile (ms); null without LCP samples
impact[].variants[].avgClsnumber | nullAverage CLS score; null without CLS samples, and also when the average is exactly 0
excludedobject | nullUntrusted 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

ParameterRequiredDescription
projectIdYesProject ID
projectSlugNoIgnored (deprecated). Admin events are matched by project id or by the project's stored slug.
rangeNoTime 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
  }
}
FieldTypeDescription
funnel.steps[].eventstringFunnel step: screen_viewed, flag_viewed, or activation
funnel.steps[].usersnumberActors who reached the step
funnel.steps[].conversionRatenumberShare of entry actors who reached the step
funnel.steps[].dropOffRatenumberShare that dropped from the previous step
experimentCycleTime.sampleSizenumberExperiments with both start and decision events
experimentCycleTime.medianHoursnumber | nullMedian 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

ParameterRequiredDescription
projectIdYesProject ID
projectSlugNoIgnored (deprecated). Admin events are matched by project id or by the project's stored slug.
rangeNoTime 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
    }
  ]
}
FieldTypeDescription
cohorts[].weekstringISO timestamp for the start of the cohort week
cohorts[].createdProjectsnumberProject-week cohorts with created flags
cohorts[].adoptedProjectsnumberProject-week cohorts with matching flag views
cohorts[].adoptionRatenumberadoptedProjects / 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

ParameterRequiredDescription
siteIdYesSite ID (site_…, the data-site-id in the script tag) or project ID
limitNoNumber of events (max 100, default: 50)
includeUntrustedNo1 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" }
    }
  ]
}
FieldTypeDescription
events[].typestringEvent 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[].urlstringPage URL where the event occurred, scrubbed by the ingest
events[].timestampstringWhen the event happened (never later than when it was received)
events[].country, events[].browserstringAdded by the ingest
events[].flagsobjectActive flag variants at event time
events[].dataobjectEvent-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

ParameterRequiredDescription
projectIdYesProject ID
environmentNoEnvironment name (default production)
periodNoTime period: 1h, 24h, 7d, 30d (default: 24h)
serviceIdNoOne 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).
eventNameNoCustom 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

ParameterRequiredDescription
projectIdYesProject ID
flagKeyYesFlag key to segment by
stepsYesComma-separated event names defining the funnel (2 to 10)
environmentNoEnvironment name (default production)
periodNo1h, 24h, 7d or 30d (default: 7d)
serviceIdNoFilter 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
}
FieldTypeDescription
funnelSteps[].stepstringEvent/step name
funnelSteps[].totalUsersnumberUsers who reached this step
funnelSteps[].byVariantobjectUser count per flag variant
funnelSteps[].dropOffRatenumberFraction 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

ParameterRequiredDescription
serviceIdNoOne service of the project
includeUntrustedNo1 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"
}
FieldRequiredDescription
projectIdYesProject ID
destinationYeswebhook, the only destination
webhookUrlYesHTTPS 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
signingSecretNoHMAC-SHA256 signing secret for webhook verification
dataTypesNoData types to export (default: all)
periodNoTime 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 fetch while the page is open sends application/json. At page hide it sends the same JSON as text/plain in a keepalive fetch (with navigator.sendBeacon where fetch has no keepalive, or where nothing more fits the 64 KB keepalive allowance). 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, default or the public showcase IDs) gets 403.
  • 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 fieldRule
typepageview, webvital, click, error, scroll or custom. Conversions are custom events with data.conversion: true.
siteIdThe same site ID in every event of the batch
sessionIdA non-empty string of at most 128 characters
timestampEpoch 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.
urlA string. URLs longer than 4,096 characters are cut, after scrubbing.
flagsAn object (any other value is stored as null)
dataAn 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
}
StatusWhen
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).
400The 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.
413The 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.
429Over a rate limit (see Rate Limits), with Retry-After: 60
503Storage 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 as text/plain), at most 2 MiB. A bigger body gets 413 {"error": "Payload too large"} before its credential is checked, whether its Content-Length says so or it streams past 2 MiB without one (the rest isn't read). A body that can't be read gets 400.
  • Authentication: the project's SDK key or a project API token, as Authorization: Bearer <key> or as apiKey in the JSON body (a request sent as the page closes can't carry the header without a CORS preflight). @flaggr/sdk 0.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 an Authorization header, an apiKey that isn't printable ASCII without spaces (no key is anything else) gets 401 TELEMETRY_AUTH_INVALID. Personal access tokens get 403 TELEMETRY_CREDENTIAL_NOT_ALLOWED; console sessions, connector tokens and unknown or revoked tokens get 401 TELEMETRY_AUTH_INVALID. A batch without a credential gets 401 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 is 403 TELEMETRY_SCOPE_MISMATCH. That includes a siteId or serviceId holding 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 the serviceId the batch names. When its scopes don't cover that service, or the batch has none, it gets 403 TELEMETRY_FORBIDDEN instead 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 gets 403 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 _untrusted unless the request's Origin (or Referer, 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 no Origin, 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, the data URL 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's next) 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 as redacted. 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 in processed.
  • Responses: 200 {"success": true, "processed": {…}} when the batch is accepted; its rows are written after the response. 503 TELEMETRY_CHECK_UNAVAILABLE when 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:

  1. With a credential: the project's SDK key or a project API token (Authorization: Bearer <key>). siteId may be the project's ID or one of its sites. Rows get data._src = "sdk".
  2. Without one: siteId must be a site's own ID. Project IDs, default and the public showcase IDs are refused. Rows get data._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.

StatusWhen
202{"accepted", "rejected", "siteId"}: the accepted events are stored
400Invalid JSON, an empty batch, or more than one siteId
401MOBILE_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)
403MOBILE_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)
413More than 100 events, or a body over 256 KB
429Rate limited (per address)
500The write failed
503MOBILE_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 gets 413 {"error": "Payload too large"} before its credential is checked, whether its Content-Length says so or it streams past 256 KB without one; a batch over 100 events gets 413 {"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 gets 403 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 get 403 SERVER_EVENTS_CREDENTIAL_NOT_ALLOWED; sessions, connector tokens and unknown or revoked tokens get 401 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 serviceId is 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 in rejected.
  • Event fields: kind ("error" or "span", required), timestamp (ISO, required; more than 24 hours ahead is rejected), and optional serviceId, environment, fingerprint, name, message, stack, route, method, statusCode (integer), durationMs (integer), release and data (an object). An error sent without a fingerprint gets 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 for message and stack, 512 for the rest; over-cap or wrongly typed fields reject the event, never truncate it. An event that fails any check is counted in rejected; the rest of the batch is stored.
  • Scrubbing: message and stack are scrubbed as free text, route as a URL or text, and every string of data, 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.traceId or data.trace_id) falls under the server's rate (10% by default, SERVER_TELEMETRY_SPAN_SAMPLE_RATE from 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 (statusCode 500 or more, or data.error: true) are always kept. Each kept span stores data._sampleRate, the rate it survived: the sender's own data.sampleRate (0 to 1, if it head-samples) times the server's, or the sender's alone for a failed span. A _sampleRate the 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_LIMITED with Retry-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), so accepted counts the events that passed validation, sampling and the quotas, not the rows the database took. 503 SERVER_EVENTS_CHECK_UNAVAILABLE (with Retry-After: 30) when the credential or a service lookup couldn't run; nothing is stored. 500 only 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 returns 401 {"code":"ADMIN_EVENTS_AUTH_REQUIRED"}. API tokens, SDK keys, personal access tokens and connector tokens get 403 {"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, else properties.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 413 whether or not the request declares its length; every event must name the same siteId, else 400
  • Allowed site IDs: flaggr_admin_prod, flaggr_admin_preview, flaggr_admin_dev, or the configured NEXT_PUBLIC_FLAGGR_ANALYTICS_SITE_ID / FLAGGR_ANALYTICS_SITE_ID
  • Timestamp window: occurredAt must 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 route property (its path and query) loses its fragment and has credential-like query values redacted, including those inside a nested next= URL. A userId that holds an email address or a JWT is stored as null. 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

ParameterRequiredDescription
serviceIdYesService identifier
environmentNoTarget environment (production, staging, development)
rangeNoEvaluation discovery range: 1h, 24h, 7d, 30d (default: 24h)
windowMinutesNoDuration window in minutes before and after the toggle

Response Fields

FieldTypeDescription
impact.hasRecentTogglebooleantrue if at least one toggle event was found in the audit history
impact.healthStatusstringHealth classification: healthy, warning, degraded, insufficient_data, or none
impact.latestToggleobjectTimestamp, actor email/ID, transition (from -> to), duration in ms
impact.beforeobjectEvaluations, errors, error rate, avg latency, and P99 latency before toggle
impact.afterobjectEvaluations, errors, error rate, avg latency, and P99 latency after toggle
impact.deltaobjecterrorRateDelta (+/- rate shift), latencyP99DeltaMs (latency change), evaluationsRatio
impact.recommendationstringAutomated operational advice (e.g. recommend immediate rollback on degradation)
recentTogglesarrayRecent 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

ParameterRequiredDescription
serviceIdYesService identifier
environmentNoFilter by environment
rangeNo1h, 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

ParameterRequiredDescription
rangeNo15m, 1h, 24h, 7d (default 1h)
serviceIdNoFilter to specific service
environmentNoFilter 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 ToolDescription
get_browser_analytics_overviewA site's visitors, page views, session duration and bounce rate against the previous window (/api/analytics/overview)
get_web_vitalsA site's Web Vitals percentiles, breakdowns and render diagnostics (/api/analytics/vitals)
list_server_eventsBackend errors and request spans a project's services reported (newest first, filters for kind, fingerprint and window)
get_server_event_summaryWindow aggregates of that server telemetry: error/span counts, top error fingerprints, span p50/p95 durations
get_flag_toggle_impactPost-toggle health, drift metrics, before/after table, and rollback recommendations
get_flag_metricsVolume, rates, percentiles, reason breakdown, and time series
get_evaluation_heatmapEvaluation usage grid across all flag x service x environment cells
get_project_healthPlatform-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