Analytics
GET /v1/analytics returns what the dashboard Analytics page shows: overview, one link, breakdowns, trend, links leaderboard, and link comparison.
GET /v1/analytics reads one of seven surfaces through the same authorized query service behind the dashboard's Analytics page and the MCP query_analytics tool. A REST call and an MCP call with the same input return identical numbers. The dashboard's breakdown grid is the one exception: a filtered card keeps every value of its own dimension visible instead of narrowing it, so a chosen row never hides its siblings, and its rows can differ from the same filter sent to REST or MCP.
Authentication and Permissions
Requires an API key with the analytics:read or links:read scope. SDK keys are refused. Retention and the Free plan limits apply exactly as they do on the dashboard. A window outside your plan's retention comes back as a named state rather than a silent zero. A Free organization reads the last 30 days: a range, or a comparison period, that starts earlier is refused with 403 LOOKBACK_EXCEEDED, and details.earliest_allowed is the first instant it may read. A Free organization at its 10,000-click limit still reads, with figures that stop at the moment the limit was reached: the receipt carries paused as { reached_at, limit }, window_effective.until is one millisecond after that moment (or at it, when another click shares that millisecond, and then the clicks in that millisecond are left out), and a warning says so.
Surfaces
surface | Answers |
|---|---|
overview | Organization-wide totals for a window |
link | One link's detail (link_id required) |
explore | A single-dimension breakdown, with optional filter and compare |
change | A comparison of 2 to 5 links (link_ids required) |
summary | The key numbers (clicks, unique clicks, installs, click to install rate, app open share) with change and sparkline, the headline sentence, the top movers, and a spike flag |
trend | The chart series by granularity (hour, day, or week), with the previous period aligned bucket by bucket |
leaderboard | Links by change (tab=movers), links created in the window (tab=new), or active links with no taps (tab=silent) |
A parameter that does not apply to the chosen surface, and any unrecognized query parameter, returns 400 VALIDATION_ERROR.
Query Analytics
/analyticsAnswer an analytics question
Reads one of seven analytics surfaces through the same authorized query service the dashboard and the MCP query_analytics tool use: overview, one link's detail, a breakdown of one dimension (explore), a comparison of 2-5 links (change), and the analytics page's own signals, summary (KPIs, headline sentence, top movers, spike flag), trend (the chart series by hour, day or week with the previous period aligned bucket by bucket) and leaderboard (links by change, new links, active links with no taps). Requires an API key with the analytics:read or links:read scope. SDK keys are refused. Retention, the Free plan's 30-day look-back and the Free click-limit freeze apply exactly as on the dashboard. A Free range, or a comparison whose earlier period, that starts before the last 30 days is refused with 403 LOOKBACK_EXCEEDED, and details.earliest_allowed carries the first instant a Free organization may read. A Free organization at its 10,000-click limit still reads: figures stop at the moment the limit was reached, receipt.paused is { reached_at, limit }, window_effective.until is one millisecond after that moment (or at it, when another click shares that millisecond, and then the clicks in that millisecond are left out), and a warning says so. Clicks keep being stored, and a new billing period reads live again. Reports accepted_taps, unique_clicks, installs (observed_attributed_installs, or linked_installs when a filter or own_links scope narrows the view) and, on summary and trend, tap_to_install_rate and app_open_share as numerator and denominator. Days, weeks and hours are labeled in the organization's reporting time zone unless timezone is sent. metrics optionally narrows the response to a subset of a surface's own metrics. app_id filters every surface to links of one app in the org. On the overview surface the rows also carry the top countries, referrers and devices, idle links, recent clicks and the previous-period comparison row, and attempt_match_rate is reported with an additive tap_found object ({ numerator, denominator, display, status }): the match rate among new installs where a tap was found, with status healthy (80% or more), needs_attention (50% to under 80%), low (under 50%), or not_enough_installs (fewer than 50 such installs); on the link surface the tap-source split; on change the per-compared-link breakdowns. A parameter not applicable to the chosen surface, and any unrecognized query parameter, is a 400 VALIDATION_ERROR. A parameter other than filter sent more than once is also a 400 VALIDATION_ERROR, and so is org: the API key's own organization is always the one queried. On the leaderboard surface, insight.silent_through and insight.silent_state say whether silence can be claimed for the silent tab. A sparkline point that cannot be computed, such as a rate with no denominator, is null.
Parameters
| Name | Type | In | Description |
|---|---|---|---|
surface* | "overview" | "link" | "explore" | "change" | "summary" | "trend" | "leaderboard" | query | Which question to answer. |
window_preset | "last_7_days" | "last_30_days" | "this_month" | "previous_month" | query | A relative window. Omit and pass from/to instead for an explicit range. |
from | string | query | Start of an explicit window, inclusive. Required together with to when window_preset is omitted. |
to | string | query | End of an explicit window, exclusive. |
timezone | string | query | IANA timezone that labels every day, week and hour and resolves this_month/previous_month. Defaults to the organization's reporting time zone (Settings), else UTC. |
link_id | string | query | Required when surface=link. |
link_ids | string | query | Comma-separated link ids (2-5 distinct). Required when surface=change. |
dimension | "country" | "device" | "os" | "browser" | "referrer" | "utm_source" | "source" | "app" | query | Required when surface=explore, unless dimensions is sent. Exactly one dimension name. Send dimension or dimensions, not both. |
dimensions | "country" | "device" | "os" | "browser" | "referrer" | "utm_source" | "source" | "app" | query | Required when surface=explore, unless dimension is sent. Exactly one dimension name; the plural form is the canonical shape but only one value is accepted today. Send dimension or dimensions, not both. |
metrics | string | query | Comma-separated subset of the surface's own metric list (e.g. metrics=accepted_taps,unique_clicks). Omit for every metric the surface reports. A name outside the surface's own list is a 400 VALIDATION_ERROR. |
filter | string[] | query | Repeatable filter, one token per value. Two spellings are accepted. Plain, dimension:value (filter=country:US): an empty value (country:) selects Unknown, repeating the parameter for one dimension matches any of its values (filter=country:US&filter=country:DE, at most 25 values per dimension), and different dimensions combine with AND. Typed, dimension:operator[:payload]: eq (filter=country:eq:US, the value after the second colon is taken exactly as given, including any further colons, commas or percent signs, e.g. filter=utm_source:eq:partner:summer keeps the whole value 'partner:summer'), is_unknown (filter=country:is_unknown, no payload) and in (a true OR of 2-5 comma-separated values, filter=utm_source:in:google,bing; a value that itself contains a comma, colon, percent sign or non-ASCII text must be percent-encoded with encodeURIComponent before joining the list, e.g. filter=utm_source:in:a%2Cb,plain for the two values 'a,b' and 'plain'; a malformed percent-escape is a 400 VALIDATION_ERROR, never silently dropped or truncated). A token whose second segment is a valid typed operator reads as typed. At most one in filter per request, and none together with compare. Accepted on explore, summary, trend and leaderboard. |
app_id | oneOf | query | Three states. Omit for every link regardless of app (unfiltered, the default). A UUID filters every surface to links of that one app in the org (404 if the app does not exist in this org). The literal string "none" selects only links with no app at all. On summary, trend and leaderboard it is the same as a filter on the app dimension, so it cannot be sent together with one. |
compare | "previous" | "none" | "true" | "false" | query | Also read the equal-length period just before (previous, or true) or not (none, or false). Defaults to previous on summary, trend and leaderboard and to none on explore. Not accepted on overview, link and change. |
granularity | "hour" | "day" | "week" | query | summary, trend, leaderboard, and explore with row_series=true. Chart bucket size. hour needs a window of at most 7 days, week at least 14 days; day always works. A refused choice is a 400 INVALID_GRANULARITY naming the limit. Defaults to day. |
metric | "taps" | "unique" | "installs" | "install_rate" | "app_share" | query | trend only. The KPI the caller is asking about, echoed in the response. Every metric is returned on every row. |
tab | "movers" | "new" | "silent" | query | leaderboard only. movers lists links by change, new lists links created in the window, silent lists active links with no taps under the same filters. Defaults to movers. |
search | string | query | explore only. Literal text matched against a row's value or app name; % and _ are ordinary characters. With limit it also sets how many named rows are listed. |
row_series | boolean | query | explore only. Add each row's share of the scan, previous count, change and sparkline. |
cursor | string | query | An opaque cursor from a prior response. Reauthorized against the current request; a stale or mismatched cursor is refused. |
limit | integer | query | Page size. |
Responses
200The query result: schema_version, the query receipt, totals/units, rows, and next_cursor
400Invalid request (unknown surface, missing window, a filter or granularity the surface can't honor)
| Field | Type | Description |
|---|---|---|
error* | object |
401Unauthorized
| Field | Type | Description |
|---|---|---|
error* | object |
403Forbidden (including a link outside a member's own_links scope) or missing scope. A Free organization that asks for a range, or a comparison, starting before its last 30 days gets LOOKBACK_EXCEEDED, with details.subject (range or comparison), details.lookback_days and details.earliest_allowed
| Field | Type | Description |
|---|---|---|
error* | object |
404Not found (a link outside the org, or a nonexistent link/org)
| Field | Type | Description |
|---|---|---|
error* | object |
The window is either a relative window_preset (last_7_days, last_30_days, this_month, previous_month) or an explicit from/to range; sending both is a 400. dimension is required on surface=explore (country, device, os, browser, referrer, utm_source, source, or app). filter is repeatable and only accepted on surface=explore, as dimension:value (an empty value, country:, selects Unknown). compare=true on surface=explore also reads the equal-length period immediately before the window.
timezone is an IANA zone (for example America/New_York). It labels every day, week, and hour, and resolves the calendar boundaries of this_month and previous_month. It defaults to the organization's reporting time zone, set in Settings. filter is repeatable (filter=country:US&filter=country:DE): values of one dimension match any, up to 25 per dimension, and different dimensions must all match. compare defaults to previous on summary, trend, and leaderboard; send compare=none to turn it off. Hourly buckets need a window of at most 7 days, and weekly buckets at least 14 days.
The response reports accepted_taps, unique_clicks, and installs: observed_attributed_installs, or linked_installs when a filter or a member's own-links scope narrows the view. tap_to_install_rate and app_open_share are returned as a numerator and a denominator, never as a rounded percent. A value outside retention is reported as a named state rather than a plain number, the same way the dashboard shows it.
On the overview surface, attempt_match_rate is the share of new installs tied to a link. Installs from an app in routing-only mode that were not matched are left out of the rate, because the app chose not to match them. It carries an additive tap_found object with numerator, denominator, display, and status: the match rate among new installs where a tap was found, with healthy at 80% or more, needs_attention from 50% to under 80%, low under 50%, and not_enough_installs while fewer than 50 such installs are counted.
Worked Example
curl "https://api.warplink.app/v1/analytics?surface=explore&window_preset=last_30_days&dimension=country&compare=true" \
-H "Authorization: Bearer wl_live_YOUR_API_KEY"
Comparing two links over the same window:
curl "https://api.warplink.app/v1/analytics?surface=change&link_ids=LINK_ID_1,LINK_ID_2&window_preset=last_7_days" \
-H "Authorization: Bearer wl_live_YOUR_API_KEY"
For a full CSV of raw click rows instead of an aggregated answer, see Exports.
Domains
Custom Domains API endpoints to register, list, delete, and verify the custom domains that serve your short links, with SSL and DNS status for each.
Exports
Exports endpoints to start a click export for an organization or one link, poll its status, and download the identical CSV file as often as you need.