REST API
Read your analytics and manage your account programmatically. The read API is the HTTP twin of the MCP server (same data, plain HTTP, JSON responses), and the management API gives you full CRUD over sites, keys, and account settings.
Demo access
No signup required. Two public, read-only tokens give you live access to abner.app's own analytics: a demo management token scoped to the demo account, and a demo site token scoped to its one site.
| Key | Token |
|---|---|
| Demo management key | abner_ws_demo_portfolio_readonly |
| Demo site key | abner_demo_site_readonly |
curl -s https://www.abner.app/api/v1/portfolio -H "Authorization: Bearer abner_ws_demo_portfolio_readonly"
curl -s https://www.abner.app/api/v1/stats -H "Authorization: Bearer abner_demo_site_readonly"
Both tokens are read-only. A POST, PATCH, or DELETE made with the demo management token returns 403:
{ "error": { "code": "read_only", "message": "Demo access is read-only." } }
The demo site key is scoped to read endpoints only, same as any site key. The demo management token (abner_ws_demo_portfolio_readonly), not the site key, also works as an MCP bearer, no OAuth flow needed; see Try it now on the MCP docs page.
Authentication
All requests use a bearer token. There are two kinds of key, each with its own scope:
| Key | Prefix | Scope | Created from |
|---|---|---|---|
| Site key | abner_ | Read-only analytics for one site | Site → Settings → API key |
| Management key | abner_ws_ | Full CRUD across one account | Account → Settings → Management API Keys |
Authorization: Bearer abner_xxxx… # read endpoints
Authorization: Bearer abner_ws_xxxx… # management endpoints
The full key is shown once at creation; only a hash is stored, so save it somewhere safe. Treat keys like passwords; never embed them in client-side code (responses are not CORS-enabled, so the API is intended for server-side use). A missing or wrong-type key returns 401 Unauthorized — read endpoints require a site key, management endpoints require a management key.
Base URL
https://www.abner.app/api/v1
Vocabulary
The query endpoints share one vocabulary. GET /api/v1/meta returns the live, authoritative lists; the current values are:
- Periods:
24h,7d,14d,30d,month,year,all - Metrics:
visitors,pageviews,sessions,pageviews_per_visitor,events - Dimensions:
pathname,referrer_host,country,region,city,browser,device_type,utm_source,utm_medium,utm_campaign,event_name(plustime_day/time_hourfor time series) - Filters (as
filter.<key>=value):pathname,referrer_host,country,region,city,browser,device_type,utm_source,utm_medium,utm_campaign,event_name
Time selection, comparison, pagination & CSV
Every query endpoint (/stats, /timeseries, /breakdown, /metrics, /vitals, /funnel) accepts these on top of the vocabulary above:
- Custom date range:
date_fromanddate_to(both required together;YYYY-MM-DDor RFC3339) are mutually exclusive withperiod: supplying both on the same request returns a 400invalid_date_range. Date-onlydate_tois inclusive of that whole day; the resolved window itself is a half-open[date_from, date_to)range, so an exact timestamp ondate_tois excluded. The span is capped at 366 days. All periods and buckets resolve in the site's timezone. - Comparison:
compare=previous_periodon/stats(and/metricswithout dimensions) adds acomparisonobject keyed by metric name, each an object of{ "current", "previous", "change_pct" }against the immediately preceding window of equal length.change_pctisnullwhen the previous value was zero. All arithmetic happens server-side. - Pagination & sort (on
/breakdownand/metrics):limit,offset,sort(a requested metric) andorder=asc|desc. Responses carry ametablock (limit,offset,count,has_more). - CSV: add
format=csv(or sendAccept: text/csv) to/breakdownor/metricsfor a downloadable CSV.
Liveness
GET /site, GET /sites, GET /sites/{site_id}, and each row of GET /portfolio carry last_event_at (RFC3339 or null if the site has never received an event) and a derived status:
live: an event within the last 24 hoursquiet: last event between 24 hours and 7 days agosilent: last event over 7 days ago, or never
OpenAPI & SDKs
A machine-readable OpenAPI 3.1 contract is served (unauthenticated) at GET /api/v1/openapi.json. Point Swagger UI, Postman, or an SDK generator (e.g. openapi-generator) at it to get a typed client in your language.
Rate limits
Requests are rate-limited per key: 600/minute for read endpoints and 120/minute for management endpoints. Every response carries RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset (seconds). Over the limit returns 429 with Retry-After. Every response also echoes an X-Request-Id you can quote when reporting an issue.
Response shape
Success responses return the resource directly: single objects at the top level, collections under a single named key (sites, results, rows, members, …). Errors always use the { "error": { "code", "message" } } envelope, including 404 and 405 responses on /api/ paths (a wrong method also returns an Allow header).
Read API
Authenticated with a site key (abner_…). All read endpoints are GET and scoped to that key's site.
GET /site
Returns the site the key is scoped to. Useful as a "whoami" / key-check.
curl https://www.abner.app/api/v1/site \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"name": "Example",
"domain": "example.com",
"timezone": "UTC",
"account_id": "1a2b…",
"last_event_at": "2026-08-18T10:12:00Z",
"status": "live"
}
GET /stats
A single overview row of aggregate metrics for the period. Query params: period (default 7d) or date_from/date_to, compare=previous_period, any filter.<key>.
curl "https://www.abner.app/api/v1/stats?period=30d&filter.country=US" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"period": "30d",
"stats": { "visitors": 1240, "pageviews": 3877, "sessions": 1502, "pageviews_per_visitor": 3.12, "events": 84 }
}
With compare=previous_period, a comparison object is added, one entry per metric:
curl "https://www.abner.app/api/v1/stats?period=30d&compare=previous_period" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"period": "30d",
"stats": { "visitors": 1240, "pageviews": 3877 },
"comparison": {
"visitors": { "current": 1240, "previous": 980, "change_pct": 26.53 },
"pageviews": { "current": 3877, "previous": 4210, "change_pct": -7.91 }
}
}
A custom range instead of period:
curl "https://www.abner.app/api/v1/stats?date_from=2026-07-01&date_to=2026-07-31" \
-H "Authorization: Bearer $ABNER_API_KEY"
GET /timeseries
Metrics bucketed over time. Query params: period (default 30d), interval = day | hour (default day), metrics (comma-separated, default visitors,pageviews), filters.
curl "https://www.abner.app/api/v1/timeseries?period=7d&interval=day&metrics=visitors" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"period": "7d",
"interval": "day",
"points": [
{ "time": "2026-05-19T00:00:00Z", "metrics": { "visitors": 180 } },
{ "time": "2026-05-20T00:00:00Z", "metrics": { "visitors": 205 } }
]
}
GET /breakdown/{dimension}
Top values for a single dimension (e.g. top pages, referrers, countries). Path: any dimension except the time buckets. Query params: period (default 7d), metrics (default visitors,pageviews), limit (default 50, max 1000), filters.
curl "https://www.abner.app/api/v1/breakdown/pathname?period=30d&limit=5" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"period": "30d",
"dimension": "pathname",
"results": [
{ "value": "/", "metrics": { "visitors": 820, "pageviews": 1610 } },
{ "value": "/pricing", "metrics": { "visitors": 210, "pageviews": 340 } }
]
}
GET /metrics
The swiss-army query: combine any metrics, dimensions, filters, and period. This is the direct mirror of the MCP query_metrics tool. Query params: metrics (required, comma-separated), dimensions (optional, comma-separated), period or date_from/date_to, compare=previous_period (overview only, i.e. no dimensions), limit, filter.<key>.
curl "https://www.abner.app/api/v1/metrics?metrics=visitors,pageviews&dimensions=country,browser&period=7d&limit=10" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"period": "7d",
"rows": [
{ "dimensions": { "country": "US", "browser": "Chrome" }, "metrics": { "visitors": 412, "pageviews": 980 } }
]
}
With no dimensions you get a single overview row (same as /stats), and compare=previous_period adds the same per-metric comparison object shown above. Add time_day or time_hour as a dimension to get a time series, where each row carries a time field.
GET /realtime
Unique visitors active in the last 5 minutes plus the most recent events. Query param: limit (default 50, max 200).
curl "https://www.abner.app/api/v1/realtime?limit=20" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"active_visitors": 7,
"recent_events": [
{ "event_type": "pageview", "url": "https://example.com/x", "pathname": "/x", "country": "US", "browser": "Chrome", "timestamp": "2026-05-25T11:34:00Z" }
]
}
GET /vitals
p75 Core Web Vitals over the period. Query params: period (default 30d) or date_from/date_to. Note: fid_p75 is retained for continuity; inp_p75 (INP) is the current responsiveness metric.
curl "https://www.abner.app/api/v1/vitals?period=30d" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"period": "30d",
"vitals": { "lcp_p75": 1.8, "fid_p75": 12, "cls_p75": 0.04, "fcp_p75": 1.1, "ttfb_p75": 0.6, "inp_p75": 180 }
}
GET /funnel
A stateless, ordered per-session conversion funnel over inline steps, with nothing stored server-side; pass the steps you want each time. This is the direct mirror of the MCP query_funnel tool. Query params: steps (required, repeatable, 2-8 ordered step specs), period (default 7d) or date_from/date_to, window_seconds (default 86400, max 604800).
Each step is path:<pathname> (e.g. path:/pricing) or event:<name> (e.g. event:signed_up). Steps must occur in order within one session, within window_seconds of the first step.
curl "https://www.abner.app/api/v1/funnel?steps=path:/pricing&steps=event:signed_up&period=30d" \
-H "Authorization: Bearer $ABNER_API_KEY"
{
"site_id": "9f3c…",
"period": "30d",
"steps": [
{ "step": 1, "label": "path:/pricing", "sessions": 820, "conversion_rate": 100, "dropoff_rate": 0 },
{ "step": 2, "label": "event:signed_up", "sessions": 164, "conversion_rate": 20, "dropoff_rate": 80 }
]
}
Management API
Authenticated with a management key (abner_ws_…), these endpoints manage everything in the account the key belongs to. Reads return 200, creates 201, deletes 204. PATCH updates only the fields you send.
Sites
| Method & path | Description |
|---|---|
| GET /api/v1/sites | List all sites in the account, each with last_event_at and status |
| POST /api/v1/sites | Create a site — body { "name", "domain", "timezone"? } |
| GET /api/v1/sites/{site_id} | Fetch one site, with last_event_at and status |
| PATCH /api/v1/sites/{site_id} | Update — body any of { "name", "domain", "timezone" } |
| DELETE /api/v1/sites/{site_id} | Delete a site and its related Postgres plus ClickHouse data |
curl -X POST https://www.abner.app/api/v1/sites \
-H "Authorization: Bearer $ABNER_WS_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Marketing Site","domain":"example.com"}'
{ "site_id": "…", "name": "Marketing Site", "domain": "example.com", "public_id": "…", "timezone": "UTC", "account_id": "…", "created_at": "…", "last_event_at": null, "status": "silent" }
Portfolio
GET /api/v1/portfolio returns one row per site in the account, with metrics, a built-in previous-period comparison, and liveness, in a single call. Query params: period (default 7d) or date_from/date_to, metrics (comma-separated, default visitors,pageviews; visitors is always included even if you leave it out). Comparison is always on, and the window resolves in UTC since it spans sites in different timezones. Accounts with more than 500 sites are truncated after sorting by name (truncated: true). The sites array itself is ordered by the largest absolute visitors change first, ties broken by current visitors.
curl "https://www.abner.app/api/v1/portfolio?period=7d&metrics=visitors,pageviews" \
-H "Authorization: Bearer $ABNER_WS_KEY"
{
"period": "7d",
"sites": [
{
"site_id": "9f3c…",
"name": "Example",
"domain": "example.com",
"last_event_at": "2026-08-18T10:12:00Z",
"status": "live",
"metrics": {
"visitors": { "current": 1240, "previous": 980, "change_pct": 26.53 },
"pageviews": { "current": 3877, "previous": 4210, "change_pct": -7.91 }
}
}
],
"truncated": false
}
Changes
GET /api/v1/changes returns the sites in the account whose visitor count moved beyond the noise floor, ranked, each with its top contributing pathnames and referrer hosts. Query params: period (default 7d) or date_from/date_to (same UTC window as /portfolio).
A site is dropped as noise when its current-plus-previous visitors total is below 50. Of the rest, a site is listed when its absolute percent change is at least 30, ranked by absolute percent change descending (a move from zero visitors ranks first). Each listed site carries its top 3 contributing pathname and referrer_host values by absolute visitor delta. An empty changes list means nothing moved beyond the thresholds, which is itself the answer, not an error.
curl "https://www.abner.app/api/v1/changes?period=7d" \
-H "Authorization: Bearer $ABNER_WS_KEY"
{
"period": "7d",
"changes": [
{
"site_id": "9f3c…",
"name": "Example",
"domain": "example.com",
"direction": "up",
"visitors": { "current": 1240, "previous": 640, "change_pct": 93.75 },
"contributors": {
"pathname": [
{ "value": "/blog/launch", "current": 480, "previous": 40, "delta": 440 },
{ "value": "/pricing", "current": 210, "previous": 180, "delta": 30 }
],
"referrer_host": [
{ "value": "news.ycombinator.com", "current": 390, "previous": 0, "delta": 390 }
]
}
}
],
"thresholds": { "min_volume": 50, "change_threshold_pct": 30 }
}
Site read keys
Mint and revoke the per-site read keys used by the read API. The raw key is returned once on creation.
| GET /api/v1/sites/{site_id}/keys | List a site's read keys (metadata only) |
| POST /api/v1/sites/{site_id}/keys | Create — body { "label"? }; response includes key once |
| POST /api/v1/sites/{site_id}/keys/{key_id}/rotate | Rotate — mints a replacement (same label), revokes the old key, returns the new key once |
| DELETE /api/v1/sites/{site_id}/keys/{key_id} | Revoke a read key |
Account
| GET /api/v1/account | Fetch the current account |
| PATCH /api/v1/account | Update, body { "name" } |
Billing is managed in the web UI and the Stripe billing portal, not the API.
Sending events
To record events server-side, use the public ingest endpoint, which is authenticated by your site's public ID (not an API key). See Installation for details.
POST /api/ingest/
Content-Type: application/json
{ "site_id": "your_public_id", "url": "https://example.com/page", "referrer": "https://google.com" }
Errors
Errors return a JSON envelope with a stable machine-readable code and a human-readable message:
{
"error": { "code": "invalid_metric", "message": "Unknown metric 'foo'. See GET /api/v1/meta for the supported list." }
}
Status codes:
200/201/204— Success (read / created / deleted)400— Invalid parameter or body (invalid_period,invalid_metric,invalid_dimension,invalid_filter,invalid_interval,invalid_date_range,missing_metrics,missing_steps,invalid_steps,invalid_body,invalid_timezone,invalid_site_id,invalid_key_id)401— Missing or wrong-type API key (unauthorized)404— Resource or endpoint not found (not_found,user_not_found)405— Wrong method for this endpoint; see theAllowheader (method_not_allowed)429— Rate limit exceeded; seeRetry-After(rate_limited)500— Unexpected server error (internal_error)503— Analytics backend temporarily unavailable (analytics_unavailable)
Versioning
The API is versioned in the path (/api/v1). Additive changes (new endpoints, new response fields, new optional params) ship without a version bump, so write clients that ignore unknown fields. Breaking changes ship under a new version prefix, and a removed endpoint is announced with a Sunset header ahead of removal. The code in an error envelope is a stable contract; the message text may change.