Analytics API

Pull Apphud analytics — chart data and Business Overview widgets — into your own BI tools

Overview

The Analytics API returns the same numbers you see in Apphud's Reports and Dashboard, as JSON.

It is built for server-to-server (S2S) integrations: BI pipelines, data warehouses, scheduled
reports, internal dashboards and alerting. It is read-only — every endpoint is a query, nothing
in your account is ever modified.

  • Base URL: https://api.apphud.com/api/public/v1
  • Format: JSON request and response bodies
  • Auth: Authorization: Bearer <api_key>
📘

Availability

The Analytics API is available on Expert + plans.

What you can query

Surface in the dashboardAPI endpointsKey you need
Reports — per-app metrics with full segment and filter control/chart/*App-level S2S key
Dashboard — the single-number tiles for one app/overview/widgets*App-level S2S key

Creating an API key

Apphud issues S2S keys at two levels. Pick the one that matches the scope of your integration.

App-level key

Use this when your integration reads data for one application. This is the common case, and it
is enough for everything in this document.

Go to App settings → Authentication Center → New S2S Key.

Organization-level key

An organization-level key has access to every application in the organization, so one key can
query several apps. Use it when a single pipeline pulls data for more than one app.

Go to Organization Settings and generate an API key there.

Using the key

Send the key on every request:

Authorization: Bearer sk_your_api_key_here
  • A missing, malformed or unknown key returns 401 unauthorized.
  • Requesting an app_id the key has no access to returns 403 forbidden.

Request basics

app_id

Every chart and widget request takes an app_id — your Apphud application identifier, an
eight-character value such as 60b9058d. You can find it in App settings. It is not the bundle
identifier.

Response envelope

Every successful response uses the same wrapper. Your payload is under data.results. data.meta
carries presentation hints for the chart (axis label, tooltips) and is null when the endpoint
produces none.

{
  "data": {
    "results": { "...endpoint specific payload..." },
    "meta": null
  },
  "errors": null,
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}

Errors use the same wrapper with data: null:

{
  "data": null,
  "errors": [
    { "id": "missing_param", "title": "additional field: Required parameter is missing" }
  ],
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736"
}

Always quote trace_id when you contact support — it identifies the exact request in our logs.

Time ranges

time_range.from and time_range.to are ISO-8601 timestamps. The UTC offset you send defines the
day boundaries
used for grouping, so send the offset your reporting uses and keep it identical in
both fields. from must be strictly earlier than to.

"time_range": {
  "from": "2026-01-01T00:00:00.000+00:00",
  "to":   "2026-03-31T23:59:59.999+00:00"
}

Rate limits

  • 60 requests per minute per API key. The limiter keys on the bearer token.
  • Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
  • Over the limit you get 429 with a plain body — this one does not use the standard envelope:
{ "error": "rate_limit_exceeded", "message": "Too many requests. Limit: 60 requests per 1m0s" }

Discovery calls (/chart/list, /chart/options) change rarely — cache them per deployment rather
than fetching them per query.

Errors

Statuserrors[].idTypical cause
400missing_paramA required field is absent, most often additional for a chart that requires it
400invalid_inputUnknown chart_id, filter, segment or sorting field
400invalid_time_rangefrom is not earlier than to
400invalid_requestMalformed body, or a chart queried through the wrong endpoint
400too_much_pointsThe range divided by view_by exceeds 190 points
400too_much_segmentMore than two segments requested
401unauthorizedMissing, malformed or unknown API key
403forbidden / no_accessThe key has no access to this app, or the chart is not in your plan
404not_foundUnknown widget
408timeoutThe query took too long — narrow the range or drop filters
429Rate limit exceeded (see above)
500internal_errorServer-side failure — retry, then contact support with the trace_id

Reports

An app-level S2S key is enough for everything in this section.

Recommended call order

You never need to hardcode identifiers — discover them from the API itself:

GET  /chart/list      → pick a chart_id, read its type and additional[]
GET  /chart/options   → discover the segments and filters that chart supports
POST /chart/filter    → discover the possible values of one filter
POST /chart/query/line     (chart type "line")
POST /chart/query/column   (chart type "column", "stackedColumn" or "columnLine")

List charts — GET /chart/list

Returns the catalog of charts available to the application, grouped the same way as the dashboard.
Start here: it gives you the chart_id, tells you which query endpoint to use, and lists every
calculation parameter the chart expects.

Query parameters: app_id (required).

curl -G https://api.apphud.com/api/public/v1/chart/list \
  -H "Authorization: Bearer $APPHUD_API_KEY" \
  -d app_id=60b9058d
{
  "data": {
    "results": {
      "chart_groups": [
        {
          "name": "Money",
          "charts": [
            {
              "id": "mrr",
              "name": "Monthly Recurring Revenue",
              "short_name": "MRR",
              "description": "MRR represents recurring proceeds revenue normalized to a monthly amount…",
              "doc_url": "https://docs.apphud.com/docs/mrr",
              "type": "line",
              "is_locked": false,
              "additional": [
                {
                  "id": "view_by",
                  "name": "View by",
                  "required": true,
                  "default_value": "86400",
                  "values": [
                    { "id": "86400",   "name": "Days"   },
                    { "id": "604800",  "name": "Weeks"  },
                    { "id": "2592000", "name": "Months" }
                  ]
                }
              ]
            }
          ]
        }
      ]
    },
    "meta": null,
  },
  "errors": null,
  "trace_id": "…"
}

How to read it:

  • type: "line" → query it with POST /chart/query/line.
    type: "column", "stackedColumn" or "columnLine"POST /chart/query/column.
  • is_locked: true → the chart exists but is not included in your plan; querying it returns 403.
  • additional[] is empty for charts that take no calculation options. When it is not empty, the
    additional object is mandatory in the query request.

The additional field

additional carries the calculation options of a chart: time grouping, revenue basis, cohort
period and so on. It is a flat object of string keys and string values:

"additional": { "view_by": "86400" }

Rules:

  1. Keys must come from that chart's additional[].id. An unknown key returns
    400 invalid additional key: <key>.
  2. Values must come from that parameter's values[].id. Anything else returns
    400 invalid value <value> for key <key>.
  3. Values are always strings"86400", never 86400.
  4. If the chart declares any parameter, omitting additional returns
    400 additional field: Required parameter is missing. The simplest correct request copies every
    default_value from /chart/list.

Parameters you will meet most often:

KeyMeaningValues
view_bySize of one data point, in seconds"86400" (days), "604800" (weeks), "2592000" (months)
view_by_cohortCohort bucket size for retention charts"time_week", "time_month", "time_quarter", "time_year"
calculate_usingRevenue basis"proceeds", "sales"
exclude_refundsRefund handling"with_refunds", "without_refunds"

The authoritative per-chart list is always /chart/list.

view_by also controls how many points a line chart returns. A request that would produce more
than 190 points is rejected — widen view_by or shorten the range.

Chart options — GET /chart/options

Returns the segments and filters the chart supports for this application.

Query parameters: app_id, chart_id (both required).

curl -G https://api.apphud.com/api/public/v1/chart/options \
  -H "Authorization: Bearer $APPHUD_API_KEY" \
  -d app_id=60b9058d \
  -d chart_id=proceeds
{
  "data": {
    "results": {
      "segment_groups": [
        {
          "label": "General",
          "only_one": false,
          "options": [
            { "value": "customer_country", "label": "Customer Country", "description": "" },
            { "value": "store_country",    "label": "Store Country",    "description": "" }
          ]
        }
      ],
      "filter_groups": [
        {
          "name": "General",
          "filters": [
            {
              "id": "customer_country",
              "name": "Customer Country",
              "description": "",
              "conditions": [
                { "value": "equals",       "label": "= (is)",                "symbol": "=" },
                { "value": "not_equals",   "label": "≠ (is not)",            "symbol": "≠" },
                { "value": "contains",     "label": "∋ (contains)",          "symbol": "∋" },
                { "value": "not_contains", "label": "∌ (does not contains)", "symbol": "∌" }
              ],
              "values": []
            }
          ]
        }
      ],
      "additional": {
        "view_by": [
          { "value": "86400",   "label": "Days"   },
          { "value": "604800",  "label": "Weeks"  },
          { "value": "2592000", "label": "Months" }
        ]
      }
    },
    "meta": null
  },
  "errors": null,
  "trace_id": "…"
}

Note the key names: segments use value / label, and segment groups use label / options. An
empty filters[].values means the values are not enumerable up front — fetch them with
/chart/filter.

Filter values — POST /chart/filter

Returns the values actually present in your data for one filter, narrowed by any filters you have
already applied.

FieldRequiredDescription
app_idyesApplication identifier
chart_idyesChart identifier
filter_idyesfilter_groups[].filters[].id from /chart/options
filtersnoFilters already applied, to narrow the result
curl -X POST https://api.apphud.com/api/public/v1/chart/filter \
  -H "Authorization: Bearer $APPHUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id":    "60b9058d",
    "chart_id":  "proceeds",
    "filter_id": "customer_country",
    "filters":   []
  }'
{
  "data": {
    "results": {
      "id": "customer_country",
      "values": [
        { "value": "US", "label": "United States" },
        { "value": "DE", "label": "Germany" }
      ],
      "input_type": "text"
    },
    "meta": null
  },
  "errors": null,
  "trace_id": "…"
}

input_type is text or number and tells you how to render a free-form filter input.

Line chart — POST /chart/query/line

Returns a time series. Use it for charts whose type is line.

FieldRequiredDescription
app_idyesApplication identifier
chart_idyesChart identifier whose type is line
time_rangeyes{ "from": ISO-8601, "to": ISO-8601 }
additionaldependsCalculation options — mandatory when the chart declares any
segmentsnoSplits the result into one line per value. Only the first entry is used. Omit for a single aggregate line.
filtersnoNarrows the data. Different filters are combined with AND.
curl -X POST https://api.apphud.com/api/public/v1/chart/query/line \
  -H "Authorization: Bearer $APPHUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id":   "60b9058d",
    "chart_id": "proceeds",
    "time_range": {
      "from": "2026-01-01T00:00:00.000+00:00",
      "to":   "2026-03-31T23:59:59.999+00:00"
    },
    "additional": { "view_by": "86400" },
    "segments": [ { "id": "customer_country" } ],
    "filters": [
      {
        "id": "customer_country",
        "values": [ { "condition": "equals", "value": "US" } ]
      }
    ]
  }'

Filter shape

{
  "id": "customer_country",
  "condition": "equals",
  "values": [
    { "condition": "equals", "value": "US" },
    { "condition": "equals", "value": "CA" }
  ]
}
FieldRequiredDescription
idyesFilter id from /chart/options
values[].conditionyesequals, not_equals, contains or not_contains — must be one of that filter's conditions[].value
values[].valueyesValue to compare against, from /chart/filter
conditionnoFilter-level condition controlling how several values combine

Several values inside one filter combine with OR by default, so the example above means
"US or CA". For a negative filter, repeat the condition at the filter level
("condition": "not_equals") and the values combine with AND instead — "neither US nor CA".

Response

{
  "data": {
    "results": {
      "lines": [
        {
          "name": "United States",
          "cnt": 1523,
          "points": [
            {
              "point": "2026-01-01T00:00:00Z",
              "is_predict": 0,
              "values": [ { "name": "Proceeds", "type": "money", "value": 15234.56 } ]
            }
          ]
        }
      ]
    },
    "meta": { "axis_x": "Date" }
  },
  "errors": null,
  "trace_id": "…"
}
  • name is the segment value of the line, or "(none)" when no segment was requested.
  • points[].point is the start of the bucket; bucket width is additional.view_by.
  • values[].type is money, percent or simple.
  • is_predict: 1 marks a projected point (LTV / CLV forecasts).
  • cnt is the number of underlying entities in that line.

A range with no data returns 200 with lines: [], not a 404.

Column chart — POST /chart/query/column

Returns rows instead of a time series. Use it for charts whose type is column,
stackedColumn or columnLine.

Same fields as the line chart, plus:

FieldRequiredDescription
segmentsnoEach segment adds a column to every row; two is the maximum. When omitted, the chart's first available segment is used.
pagination.limitnoRows per page, maximum 2000
pagination.offsetnoRows to skip
sorting.fieldno"Segment" or one of the chart's value names, e.g. "Proceeds"
sorting.orderno"ASC" or "DESC"uppercase
curl -X POST https://api.apphud.com/api/public/v1/chart/query/column \
  -H "Authorization: Bearer $APPHUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id":   "60b9058d",
    "chart_id": "renewals_performance",
    "time_range": {
      "from": "2026-01-01T00:00:00.000+00:00",
      "to":   "2026-03-31T23:59:59.999+00:00"
    },
    "segments":   [ { "id": "customer_country" } ],
    "filters":    [],
    "additional": {},
    "pagination": { "offset": 0, "limit": 100 },
    "sorting":    { "field": "Segment", "order": "DESC" }
  }'
{
  "data": {
    "results": {
      "rows": [
        {
          "segments": [ { "name": "customer_country", "value": "United States", "in_progress": false } ],
          "values":   [ { "name": "Renewals", "type": "simple", "value": 1523 } ]
        }
      ]
    },
    "meta": null
  },
  "errors": null,
  "trace_id": "…"
}

in_progress: true marks a bucket whose period has not finished yet, so its value may still change.
The response contains rows only — there is no total count. Page until you receive fewer rows than
your limit.


App dashboard

These endpoints return the single-number tiles of an app's Dashboard. An app-level S2S key is
enough
; the widget is always calculated for the one app_id you pass.

List widgets (optional) — GET /overview/widgets

📘

"List widgets" is optional

You can just copy Widget ID from the UI Dashboard and use it directly.

Returns the full widget catalog, split into now (current state) and overview (a period). Take
the id — a UUID — from either list and use it as {widgetId}.

curl https://api.apphud.com/api/public/v1/overview/widgets \
  -H "Authorization: Bearer $APPHUD_API_KEY"
{
  "data": {
    "results": {
      "now": [
        {
          "id": "01234567-89ab-cdef-0123-456789abcde1",
          "name": "MRR",
          "metrics": "mrr",
          "type": "now",
          "chart_id": "mrr",
          "description": "MRR (Monthly Recurring Revenue) represents recurring proceeds revenue…",
          "delta": 30,
          "position": 0
        }
      ],
      "overview": [
        {
          "id": "01234567-89ab-cdef-0123-456789abcdeb",
          "name": "Proceeds",
          "metrics": "proceeds",
          "type": "overview",
          "chart_id": "proceeds",
          "description": "An estimated revenue developer receives after deducting store commissions, refunds and VAT.",
          "time_ago": "last_90_days",
          "position": 10
        }
      ]
    },
    "meta": null
  },
  "errors": null,
  "trace_id": "…"
}

Available widgets:

TypemetricsWidget
nowmrrMRR
nowarrARR
nowactive_paid_subsActive paid subscriptions
nowactive_trial_subsActive trial subscriptions
nowactive_intro_subsActive intro subscriptions
nowactive_promo_subsActive promo subscriptions
nowin_billing_grace_periodIn billing grace period
nowin_billing_retry_periodIn billing retry period
overviewgross_revenueGross revenue
overviewsalesSales
overviewproceedsProceeds
overviewrefundsRefunds
overviewrefund_rateRefund rate
overviewbilling_issueBilling issues
overviewarpuARPU
overviewarppuARPPU
overviewprevented_refund_requestsPrevented refund requests
overviewsubscriptions_startedSubscriptions started
overviewtrials_startedTrials started
overviewtrials_canceledTrials canceled
overviewtrials_convertedTrials converted
overviewintro_subs_startedIntro subs started
overviewpromo_subs_startedPromo subs started
overviewnon_renewing_purchasesNon-renewing purchases
overviewnew_usersNew users

Query a widget — POST /overview/widgets/{widgetId}/query

FieldRequiredDescription
app_idyesApplication identifier
time_agonoRelative period preset
time_rangenoExplicit from / to, both with the same UTC offset
filtersnoSame filter shape as chart queries
forcenotrue bypasses the cache — slower, use sparingly

time_ago accepts today, yesterday, last_7_days, last_30_days, last_90_days,
this_month, last_month, this_year, last_year. When you send neither time_ago nor
time_range, the widget falls back to last_90_days. All time operations here use UTC.

curl -X POST https://api.apphud.com/api/public/v1/overview/widgets/01234567-89ab-cdef-0123-456789abcdeb/query \
  -H "Authorization: Bearer $APPHUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id":   "60b9058d",
    "time_ago": "last_30_days",
    "filters":  []
  }'
{
  "data": {
    "results": {
      "value": 48213.44,
      "value_overview": 41902.10,
      "percent_overview": 15.06,
      "type": "money",
      "comparison_time_range": { "from": "2026-05-01T00:00:00Z", "to": "2026-05-31T23:59:59Z" },
      "last_data_update": "2026-06-30T09:41:12Z",
      "from_cache": true
    },
    "meta": null
  },
  "errors": null,
  "trace_id": "…"
}
  • value is the widget's number for the requested period.
  • value_overview is the same metric over the comparison period, and percent_overview is the
    change between them. comparison_time_range tells you exactly which period was compared.
  • type is money, percent, int or float.
  • from_cache shows whether the answer came from the cache; last_data_update is when it was
    computed. Widget results are cached for one hour.

Widget filter values — POST /overview/widgets/filters

curl -X POST https://api.apphud.com/api/public/v1/overview/widgets/filters \
  -H "Authorization: Bearer $APPHUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "app_id":         "60b9058d",
    "widget_metrics": "proceeds",
    "filter_id":      "customer_country",
    "filters":        []
  }'

widget_metrics is the metrics field of the widget from GET /overview/widgets. The response has
the same shape as POST /chart/filter.


Invalidate cache — DELETE /cache

Drops cached chart and widget data for every application reachable by your API key. Use it after
a data backfill or a batch of late-processed refunds; it is not needed in normal operation. The next
queries for those apps are recomputed from the warehouse, so they are slower until the cache warms
up again.

curl -X DELETE https://api.apphud.com/api/public/v1/cache \
  -H "Authorization: Bearer $APPHUD_API_KEY"
{
  "data": { "results": { "invalidated_apps": 3 }, "meta": null },
  "errors": null,
  "trace_id": "…"
}

End-to-end example

Proceeds for Q1 2026, grouped by week, split by country, US and Canada only.

API=https://api.apphud.com/api/public/v1
AUTH="Authorization: Bearer $APPHUD_API_KEY"
APP=60b9058d

# 1. Find the chart and its calculation options.
curl -sG "$API/chart/list" -H "$AUTH" -d app_id=$APP \
  | jq '.data.results.chart_groups[].charts[] | select(.id=="proceeds")'
# -> type "line", additional: view_by (required, default "86400")

# 2. Find a segment to split by.
curl -sG "$API/chart/options" -H "$AUTH" -d app_id=$APP -d chart_id=proceeds \
  | jq '.data.results.segment_groups[].options[].value'
# -> "customer_country", "store_country", …

# 3. Find the values of the filter we want.
curl -s -X POST "$API/chart/filter" -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"app_id\":\"$APP\",\"chart_id\":\"proceeds\",\"filter_id\":\"customer_country\",\"filters\":[]}" \
  | jq '.data.results.values[].value'
# -> "US", "CA", "DE", …

# 4. Run the query.
curl -s -X POST "$API/chart/query/line" -H "$AUTH" -H "Content-Type: application/json" -d '{
  "app_id":   "'$APP'",
  "chart_id": "proceeds",
  "time_range": { "from": "2026-01-01T00:00:00.000+00:00", "to": "2026-03-31T23:59:59.999+00:00" },
  "additional": { "view_by": "604800" },
  "segments": [ { "id": "customer_country" } ],
  "filters": [
    { "id": "customer_country",
      "values": [ { "condition": "equals", "value": "US" },
                  { "condition": "equals", "value": "CA" } ] }
  ]
}' | jq '.data.results.lines[] | {name, points: (.points | length)}'

Daily proceeds snapshot for a scheduled job:

curl -s -X POST https://api.apphud.com/api/public/v1/overview/widgets/01234567-89ab-cdef-0123-456789abcdeb/query \
  -H "Authorization: Bearer $APPHUD_API_KEY" -H "Content-Type: application/json" \
  -d '{ "app_id": "60b9058d", "time_ago": "yesterday" }' \
  | jq '.data.results.value'

Data rules

The same rules that govern the dashboard apply to API responses:

  • Production only. Sandbox, TestFlight and staging data never appear in API responses.
  • Currency. Money metrics are returned in USD, converted at event time and frozen.
  • VAT and store commission. Proceeds are net of refunds, store commission and VAT.
  • Freshness. Same near-real-time windows as the dashboard — most charts within an hour, revenue
    charts within 15 minutes, predictions refreshed daily.
  • History can change. Proceeds and refund metrics shift retroactively when stores process
    refunds late. Re-query the period you need rather than treating a past export as immutable.
  • Cohorts. For cohort metrics, exclude users whose First Seen Date is today — the API does not
    exclude them for you.
  • Don't hardcode identifiers. Chart, segment and filter ids are stable but not contractual.
    Reading them from /chart/list and /chart/options keeps your integration working across
    dashboard updates.

Interactive reference

The full request and response schema is available as Swagger:

https://api.apphud.com/api/public/v1/swagger/index.html

Use it for field-by-field schemas and try-it-out testing during development. This page covers the
conceptual model, data rules and common usage patterns.

Business Overview

Business Overview dashboards are queried through a different endpoint, where both identifiers come
straight from the UI:

POST https://api.apphud.com/api/v2/dash/{dashboardId}/widgets/{widgetId}/query
  • Dashboard ID for API access — Dashboard actions → Rename, below the dashboard name.
  • Widget ID for API access — Widget menu → Edit, at the top of Widget details.

Body: { "time_ago": ..., "time_range": ..., "filters": [...], "force": false } — no app_id,
because the widget already knows which apps it covers. Sending both time_ago and time_range
returns 400. The response is the same shape as a dashboard widget query.

Status: this endpoint currently authenticates only via the dashboard session cookie
(access_token, a signed JWT). An Authorization: Bearer <s2s_key> header is ignored and the
request returns 401 "missing access token cookie". Organization-level S2S keys will work here once
bearer authentication is added.


Did this page help you?