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 keyabner_ws_demo_portfolio_readonly
Demo site keyabner_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 keyabner_Read-only analytics for one siteSite → Settings → API key
Management keyabner_ws_Full CRUD across one accountAccount → 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 (plus time_day / time_hour for 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_from and date_to (both required together; YYYY-MM-DD or RFC3339) are mutually exclusive with period: supplying both on the same request returns a 400 invalid_date_range. Date-only date_to is inclusive of that whole day; the resolved window itself is a half-open [date_from, date_to) range, so an exact timestamp on date_to is excluded. The span is capped at 366 days. All periods and buckets resolve in the site's timezone.
  • Comparison: compare=previous_period on /stats (and /metrics without dimensions) adds a comparison object keyed by metric name, each an object of { "current", "previous", "change_pct" } against the immediately preceding window of equal length. change_pct is null when the previous value was zero. All arithmetic happens server-side.
  • Pagination & sort (on /breakdown and /metrics): limit, offset, sort (a requested metric) and order=asc|desc. Responses carry a meta block (limit, offset, count, has_more).
  • CSV: add format=csv (or send Accept: text/csv) to /breakdown or /metrics for 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 hours
  • quiet: last event between 24 hours and 7 days ago
  • silent: 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/sitesList all sites in the account, each with last_event_at and status
POST /api/v1/sitesCreate 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}/keysList a site's read keys (metadata only)
POST /api/v1/sites/{site_id}/keysCreate — body { "label"? }; response includes key once
POST /api/v1/sites/{site_id}/keys/{key_id}/rotateRotate — 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/accountFetch the current account
PATCH /api/v1/accountUpdate, 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 the Allow header (method_not_allowed)
  • 429 — Rate limit exceeded; see Retry-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.