Analytics API
Pull Apphud analytics — chart data and Business Overview widgets — into your own BI tools
The Analytics Public API returns the same data you see in Reports and Business Overview, in JSON, for use in BI tools, data warehouses, scheduled exports, and custom dashboards.
It's a read-only HTTP API — there are no write endpoints and no actions are performed against your account. All requests are queries.
NoteAnalytics API is available on Expert + subscription plans.
Two query patterns
There are two parallel sets of endpoints, mirroring the two analytics surfaces:
| Surface in UI | API pattern | When to use |
|---|---|---|
| Reports (per-app, per-metric deep dives) | /chart/query/* | You want a specific metric for one app with full segment / filter control. |
| Business Overview (cross-app dashboards of widgets) | /overview/widgets/* | You want the exact value of a widget you already configured in a dashboard. |
Pick the pattern that matches where the data lives in the product.
Base URL
https://api.apphud.com/api/public/v1
Authentication
Send your Apphud server-to-server (S2S) token as a Bearer header:
Authorization: Bearer <token>
Two token scopes
Apphud issues S2S tokens at two scopes. Pick the scope based on which endpoints you need to call.
| Scope | Where to create | What it can call |
|---|---|---|
| App-level | App settings → Authentication center → + New S2S Key | /chart/query/* endpoints for that specific app only. |
| Organization-level | Organization settings → S2S API Keys→ + New S2S Key | /overview/widgets/* endpoints (Business Overview is Org-scoped), plus /chart/query/* for any app in the Organization. |
Organization-level is a supersetAn Organization-level token can call every endpoint an App-level token can. The reverse is not true — App-level tokens are scoped to a single app's chart data and cannot read Business Overview widgets.
Use Organization-level when your integration spans multiple apps or needs Business Overview. Use App-level when you want to scope a token narrowly to one app (for example, a per-app BI pipeline owned by a different team).
Rate limits
-
60 requests per minute per API key (default).
-
Exceeding the limit returns
429 Too Many Requests. -
The limit is applied per token — the rate limiter keys on the bearer token, falling back to the client IP when no token is present.
-
[UNRESOLVED: whether a
Retry-Afterheader is returned on429was not verified in the rate-limiter middleware.]
Versioning
The path includes a version segment (/v1).
[UNRESOLVED: beyond the static /v1 path segment, no versioning, breaking-change, or deprecation policy is encoded in backend-go. Confirm the policy with the API / product team.]
Errors
Standard error shape:
{
"errors": [
{ "code": "...", "title": "..." }
],
"trace_id": "..."
}Include trace_id when contacting support — it lets us locate the exact request in logs.
Internal errors map to these HTTP statuses and code values:
| HTTP | code | When it happens |
|---|---|---|
| 400 | parsing_error | Request body isn't valid JSON / can't be decoded. |
| 400 | validation_error | A field is missing or invalid (e.g. empty app_id, or from not before to). |
| 400 | invalid_input | Unknown chart_id / metric. |
| 401 | unauthorized | Missing or invalid Authorization bearer token. |
| 403 | forbidden / permission_denied | Token has no access to the requested app or chart. |
| 404 | not_found / resource_not_found | Widget, dashboard, or requested resource doesn't exist. |
| 408 | timeout | Query took too long — reduce the date range or simplify filters. |
| 408 | request_canceled | The client closed the connection before the response. |
| 409 | conflict / data_conflict | Concurrent or duplicate operation. |
| 429 | limit_exceeded | Rate limit or data-volume limit exceeded (see Rate limits). |
| 500 | internal_error | Unexpected server error — retry later and include the trace_id. |
| 503 | database_connection_error | Temporary database connectivity issue — retry in a few seconds. |
Failures from an upstream dependency are returned with code external_api_error and the upstream HTTP status. (The API does not use 422; field-validation problems come back as 400.)
[UNRESOLVED: business-validation errors such as "too many points" (>190) and "too many segments" (>1 via the API) are returned by typed service.Errors with their own HTTP status and code; the exact code strings live in the service-errors definitions, which weren't located in this pass.]
Data rules
The same data rules that govern UI analytics apply to API responses:
- Production only — sandbox / TestFlight / staging data does not appear in API responses. See About Analytics → Production data only.
- Currency — money metrics are returned in USD, converted at event time and frozen. See About Analytics → About Currencies.
- VAT and store commission — Proceeds are net of refunds, store commission, and VAT. See About Analytics → VAT and store commission.
- Data freshness — same near-real-time windows as the UI (most charts within 1 hour; revenue charts within 15 minutes; predictions refresh daily at 00:00 UTC).
- Historical mutability — Proceeds and refund-related metrics can change retroactively when stores process refunds late. The API always returns the current best-known value, not a snapshot of what was returned previously.
- Cohort same-day caveat — for cohort metrics, exclude users whose First Seen Date is today (see About Analytics → Near-real-time, with one exception). The API does not auto-exclude.
Time zone:
/chart/query/*—time_range.from/toare interpreted by the UTC offset you include in the ISO-8601 value (e.g.2026-01-01T00:00:00.000+05:00). Use aZ(or+00:00) suffix to query in UTC. The offset onfromandtomust match./overview/widgets/{widgetId}/query— all time operations use UTC.fromandtomust share the same offset, and relativetime_agoranges are computed in UTC.
Discover what's available
Don't hardcode segment, filter, or chart identifiers. Fetch them at runtime:
GET /chart/options— for a givenchart_id, returns the segments and filters that chart supports.POST /chart/filter— for a given filter, returns the valid values.GET /overview/widgets— lists the widgets configured on the calling Org's Business Overview, with theirwidget_idanddashboard_id.POST /overview/widgets/filters— returns valid filter values for a widget.
chart_id — mapping to the UI
chart_id — mapping to the UIchart_id is the chart's internal identifier. For most single-word metrics it matches the slug in the Reports URL — mrr, arpu, arppu, arpas, arr, proceeds, sales, refunds. Multi-word charts use underscores: cumulative_ltv, cumulative_clv, cohort_proceeds, mrr_movement, arr_movement, refund_rate. A few differ from the page slug — for example the Gross revenue chart's id is revenue.
No "list charts" endpointThe public API has no endpoint that lists every
chart_id. Once you know it, callGET /chart/optionsfor that chart's supported segments and filters. For the full id list, see the interactive Swagger or contact support.
widget_id — where to find it
widget_id — where to find itwidget_id and dashboard_id are shown in the Business Overview UI:
- Dashboard — Dashboard actions → Rename → ID for API access (below the dashboard name).
- Widget — Widget menu → Edit → ID for API access (at the top of Widget details).
See Business Overview → API access for screenshots.
You can also fetch the full list programmatically with GET /overview/widgets.
app_id
app_idapp_id is your app's bundle / package identifier — the value the API contract uses in its examples (e.g. com.example.app). [UNRESOLVED: the exact UI label / path is not derivable from backend-go (the value is resolved by the Rails callbacker); confirm in the dashboard — most likely App settings.]
Endpoint reference
Chart queries
POST /chart/query/line
POST /chart/query/lineReturns line-chart data — a time series for a metric.
Request body:
| Field | Required | Description |
|---|---|---|
app_id | yes | App identifier — see Discover what's available → app_id. |
chart_id | yes | Chart slug — see chart_id. |
time_range | yes | { "from": ISO-8601, "to": ISO-8601 }. |
segments | no | Up to 1 segment via the API (the in-product Reports UI allows 2). |
filters | no | Array of filter objects. |
additional | no | String-keyed map of chart-specific options. view_by sets granularity in seconds: "86400" (day), "604800" (week), "2592000" (month); default is day. Other keys are per-chart calculation options (e.g. calculate_using, cohort period) — fetch the valid keys / values for a given chart via GET /chart/options. |
Example request:
curl -X POST https://api.apphud.com/api/public/v1/chart/query/line \
-H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{
"app_id": "<app_id>",
"chart_id": "mrr",
"time_range": { "from": "2026-01-01T00:00:00.000+00:00", "to": "2026-03-31T23:59:59.999+00:00" },
"segments": [{ "id": "store_country" }],
"filters": [],
"additional": { "view_by": "2592000" }
}'Response shape:
{
"lines": [
{ "name": "United States", "points": [...], "cnt": 0, "trial_started": 0, "trial_converted": 0 }
]
}All successful responses are wrapped in a standard envelope; the payload above sits under data.results:
{ "data": { "results": { "lines": [ ... ] }, "meta": { ... } }, "trace_id": "..." }Each element of lines:
name— the segment value for the line (e.g."United States"), or"(none)"when unsegmented / no segment value.points[]— the time series. Each point is{ "values": [ { "name", "type", "value" } ], "point": <x-axis value, e.g. the period timestamp>, "is_predict": 0 }.valuescarries one or more metric values (type=money/percent/simple);is_predictis0for actual data and non-zero for predicted points (LTV / CLV forecasts).cnt,trial_started,trial_converted— per-line aggregate counters; populated for charts that compute them (user / conversion charts) and0otherwise.
No data for the range: still returns 200 with an empty lines: [] (or points whose values are 0) — not a 404.
POST /chart/query/column
POST /chart/query/columnReturns column-chart data — a paginated breakdown table.
Request body: same fields as the line chart (app_id, chart_id, time_range, segments, filters, additional) plus:
| Field | Required | Description |
|---|---|---|
pagination | no | { "offset": <int>, "limit": <int> }. |
sorting | no | { "field": "<column>", "order": "asc" or "desc" }. |
Response (under data.results):
{ "rows": [ { "segments": [ { "name": "United States", "value": "US", "in_progress": false } ], "values": [ { "name": "MRR", "type": "money", "value": 1234.56 } ] } ] }Pagination is offset / limit (no cursor). [UNRESOLVED: default and maximum page size, and whether a total count is returned — the response (ColumnsChart) contains only rows, with no total_count field, and no default/max limit was found in code.]
GET /chart/options
GET /chart/optionsFor a given chart_id, returns the segments and filters the chart supports.
Query params: app_id, chart_id (both required).
Response (under data.results): { "segment_groups": [...], "filter_groups": [...], "additional": { ... } }. segment_groups[].options[] are the segments you can pass in segments; filter_groups[].filters[] are the filters you can pass in filters (each with its id, conditions, and values).
curl "https://api.apphud.com/api/public/v1/chart/options?app_id=<app_id>&chart_id=mrr" \
-H "Authorization: Bearer <api_key>"POST /chart/filter
POST /chart/filterFor a given filter, returns the possible values.
Request body: { "app_id", "chart_id", "filter_id", "filters": [] } — filters is the currently-applied filter set (values are narrowed in that context).
Response (under data.results): { "id": "<filter_id>", "values": [ { "value": "US", "label": "United States" } ], "input_type": "text" }. input_type (text / number) appears for free-form filters.
curl -X POST https://api.apphud.com/api/public/v1/chart/filter \
-H "Authorization: Bearer <api_key>" -H "Content-Type: application/json" \
-d '{ "app_id": "<app_id>", "chart_id": "mrr", "filter_id": "store_country", "filters": [] }'Widget queries (Business Overview)
GET /overview/widgets
GET /overview/widgetsLists all Business Overview widgets configured on the calling Org's dashboards.
Query params: none.
Response (under data.results): { "now": [ ... ], "overview": [ ... ] } — two arrays of widgets (the "Now" and "Overview" widget types). Each widget includes its id, name, metrics, type, apps, and saved params / filters. Use a widget's id as {widgetId} in the query endpoint.
POST /overview/widgets/{widgetId}/query
POST /overview/widgets/{widgetId}/queryReturns the current value of a widget.
Request body:
| Field | Required | Description |
|---|---|---|
app_id | yes | App identifier. |
time_range | no | { "from", "to" } override (UTC — see Data rules). |
time_ago | no | Relative period preset (e.g. last_30_days, last_3_months). |
filters | no | Filter overrides. |
force | no | true bypasses the cache for this call. |
If neither time_range nor time_ago is supplied, the widget's saved period is used.
Response (under data.results): { "value": <number>, "value_overview": <comparison value>, "percent_overview": <% change vs the comparison period>, "type": "<int / money / ...>", "last_data_update": "<timestamp>", "from_cache": <bool> }. So it returns both the current value and the comparison delta.
POST /overview/widgets/filters
POST /overview/widgets/filtersReturns valid filter values for widget configuration.
Request body: { "app_id", "widget_metrics", "filter_id", "filters": [] }.
Response (under data.results): the same ChartFilterValues shape as POST /chart/filter — { "id", "values": [ { "value", "label" } ], "input_type" }.
Utility
DELETE /cache
DELETE /cacheForces Apphud to drop cached query results so the next query recomputes from fresh data.
- What it invalidates: cached chart and widget query results for every app the calling token can access. Scope is the token's apps — not a single chart or widget.
- Request: no path params and no body — just
DELETE /cachewith theAuthorizationheader. - Response:
200with{ "invalidated_apps": <number of apps cleared> }(wrapped in the standard envelope underdata.results). If the token has no apps, returns{ "invalidated_apps": 0 }. - When to use: after a known data change you want reflected immediately — e.g. a historical backfill or a batch of late-processed refunds — instead of waiting for the cache TTL.
- Cost / risk: the next queries for those apps are recomputed from the warehouse instead of served from cache, so they're slower (and heavier on the backend) until the cache warms again. Don't call it on every request — it still counts against your rate limit.
- Access: any valid token; it only affects that token's own apps.
Recipes
Common end-to-end patterns. Substitute <api_key>, <app_id>, IDs as needed.
Daily MRR snapshot
# MRR for the current month so far (UTC), monthly granularity
curl -s -X POST https://api.apphud.com/api/public/v1/chart/query/line \
-H "Authorization: Bearer <api_key>" -H "Content-Type: application/json" \
-d '{
"app_id": "<app_id>",
"chart_id": "mrr",
"time_range": { "from": "2026-06-01T00:00:00.000Z", "to": "2026-06-30T23:59:59.999Z" },
"additional": { "view_by": "2592000" }
}'The latest point in data.results.lines[0].points is the current MRR. For "current month so far", set from to the 1st of the month and to to now (or month end).
Cohort proceeds for the last completed month
curl -s -X POST https://api.apphud.com/api/public/v1/chart/query/line \
-H "Authorization: Bearer <api_key>" -H "Content-Type: application/json" \
-d '{
"app_id": "<app_id>",
"chart_id": "cohort_proceeds",
"time_range": { "from": "2026-05-01T00:00:00.000Z", "to": "2026-05-31T23:59:59.999Z" }
}'Note the chart_id is cohort_proceeds (underscore), not the cohort-proceeds doc slug.
Business Overview widget snapshot
curl -X POST https://api.apphud.com/api/public/v1/overview/widgets/<widget_id>/query \
-H "Authorization: Bearer <api_key>" \
-H "Content-Type: application/json" \
-d '{}'Use this in a scheduled job to email or post a widget value to Slack.
Note: app_id is required in the body — an empty {} returns 400. The minimum valid body is { "app_id": "<app_id>" } (omit time_range / time_ago to use the widget's saved period).
FAQ
Which token do I use — App-level or Organization-level?
If you need only chart data for one specific app, an App-level token is enough. If you need Business Overview widgets or chart data across multiple apps with a single token, use an Organization-level token. See Two token scopes for the full matrix.
Can I call /overview/widgets/* with an App-level token?
/overview/widgets/* with an App-level token?No. Business Overview lives at the Organization level, so its endpoints require an Organization-level token. When a token lacks access, the API returns 403 forbidden. [UNRESOLVED: the app-vs-org scope distinction is enforced by the Rails callbacker (every endpoint resolves the token via GetUserAppV2), not by the backend-go analytics service — confirm the exact status / code an App-scoped token gets against /overview/widgets/* with the backend team.]
How do I find the chart_id for the metric I see in Reports?
chart_id for the metric I see in Reports?It's the chart's internal id — for single-word metrics the same as the Reports URL slug (mrr, arpu, proceeds); multi-word charts use underscores (cumulative_ltv, cohort_proceeds). See chart_id — mapping to the UI.
What time zone is time_range interpreted in?
time_range interpreted in?For /chart/query/*, whatever UTC offset you put in the time_range timestamps (use Z for UTC). For /overview/widgets/*, always UTC. See Data rules.
Can I get sandbox / TestFlight data via the API?
No. The API returns only production data, matching the UI. See Data rules.
What happens to historical Proceeds I exported last week — can they change?
Yes. Refunds processed late shift historical Proceeds retroactively. Always re-query the period you need rather than relying on a previous export as immutable.
Are money metrics returned in USD or original store currency?
USD only, frozen at event time. See About Analytics → About Currencies.
Why are my segment counts different from what Reports shows?
- Reports UI supports 2 segments simultaneously; the API supports 1. If the UI report was built with 2 segments, the API can return only one dimension at a time.
- The API always combines filters with AND; a UI report built with OR logic can return different numbers.
- Data-timing and production-only rules still apply (see Data rules) — a very recent period may not have fully settled.
What if a metric has no data for the requested range?
You get 200 with an empty lines: [] (or points whose values are 0), not a 404. Treat empty lines as "no data for this range / segment", not an error.
How do I invalidate cached results?
See DELETE /cache.
Is there a sandbox / test environment for the API itself?
[UNRESOLVED: no separate sandbox / staging host for the public API was found in the analytics service config (the base host is api.apphud.com). Confirm with the team whether a dedicated test environment exists.]
Can I poll for live updates?
The API does not push or stream. Poll on a schedule that matches your freshness needs (most metrics 1 hour; revenue charts 15 minutes; predictions daily). Respect the 60 req/min limit.
Interactive reference
The full request/response schema is available as Swagger at:
https://api.apphud.com/api/public/v1/swagger
Use Swagger for field-by-field schemas and try-it-out testing during development. This page covers the conceptual model, data rules, and common usage patterns.
