Coruve

Read API · v1

Read your reports from code

Every report on your dashboard, over HTTP, as JSON. Same numbers, same filters, same exclusion rules — the API calls exactly the code the dashboard calls.

Overview

One versioned base path, one credential, one response shape.

Base URL

Base URL
https://coruve.com/api/v1

Every endpoint is a GET. The API is read-only by construction: there is no write path behind a read key, and sending events stays where it always was, at the ingest endpoint.

Availability

The read API is included on the Pro plan and up (Pro · $49/month). CSV export of every report is available on every plan, including Free. If your plan does not include it, every endpoint answers 403 PLAN_UPGRADE_REQUIRED.

Try it

Terminal
curl -s -H "Authorization: Bearer $CORUVE_API_KEY" \
  "https://coruve.com/api/v1/datasets" | jq

/datasets lists every dataset with its parameters and tells you which ones your plan can actually read — so a client discovers a plan gate before production does.

Authentication

A read key. Not the key that sends events.

Create a read key

Project Settings → API keys → Read reports. Read keys start with rk_live_ and are shown once, at creation. They are stored only as a SHA-256 hash — nobody, including us, can recover one. Revocation is immediate.

Send it as a bearer token

Terminal
# Preferred
curl -H "Authorization: Bearer rk_live_YOUR_KEY" \
  "https://coruve.com/api/v1/pages?from=2026-07-01&to=2026-07-31"

# Also accepted — the same header the ingest API uses
curl -H "X-Coruve-Key: rk_live_YOUR_KEY" \
  "https://coruve.com/api/v1/pages"

The key is never read from the query string. A credential in a URL ends up in proxy logs, browser history and Referer headers, and this is the credential that can read your data.

Two keys, two directions

An ingest key (pk_live_…) can write events and cannot read; a read key (rk_live_…) can read and cannot write. Sending the wrong one to either surface fails. That separation is the point: a leaked ingest key on a customer's server can pollute data but never exfiltrate it.

The key names the project

There is no project selector to get wrong — the key determines which project you read. You may pass ?projectId=… to assert which project you meant; if it disagrees with the key you get 403 PROJECT_MISMATCH rather than a report about the wrong site.

Endpoints

GET /api/v1/<dataset>. The names match the CSV export, so a script ports between them by changing a URL.

DatasetWhat it returnsParametersCan be nullPlan
/overviewsummaryHeadline metrics for the range: events, unique visitors, and the rest of the overview strip.——Any plan with API access
/timeserieslistDaily events and unique visitors across the range.——Any plan with API access
/pageslistMost-viewed pages, ranked by views.——Any plan with API access
/entry-pageslistPages sessions started on, ranked by sessions.——Any plan with API access
/exit-pageslistPages sessions ended on, ranked by sessions.——Any plan with API access
/referrerslistReferring domains, ranked by visitors.——Any plan with API access
/channelslistTraffic grouped into channels: search, social, paid, email, AI, referral, direct.——Any plan with API access
/campaignslistUTM-tagged campaigns, ranked by visitors.——Any plan with API access
/deviceslistVisitors by browser, OS, device type, or viewport bucket.dimension—Any plan with API access
/locationslistVisitors by country, region, or city. Coarse geography only — never coordinates.levelcountry—Any plan with API access
/sessionssummarySessions, bounce rate, average duration, and pages per session for the range.——Any plan with API access
/realtimelistThe last 30 minutes of events. Ignores from/to — the window is fixed, as it is on the report, and meta.range reports the window actually read rather than the one you asked for.——Any plan with API access
/eventslistEvent names seen on this project with their approved labels and semantic categories.——Any plan with API access
/goalslistEvery goal with its completions, sessions, conversion rate, and low-sample flag. Each goal costs a query, so one request covers at most the first 50.——Any plan with API access
/funnellistStep-by-step results for one saved funnel.funnelId*—Any plan with API access
/retentionlistRetention cohorts for IDENTIFIED users, flattened to one row per cohort and period. Coruve's anonymous visitor identifier rotates every UTC day, so cross-day retention is only measurable for visitors your site has called identify() on. When a project has too few of them the dataset returns no rows and says why in meta.notice — an empty result here is a refusal, not a measurement of zero.——Any plan with API access
/journeyslistWhat visitors did before or after a page chain.chain*direction—Any plan with API access
/ai-splitlistHuman vs AI-assisted vs crawler traffic for the range.——Any plan with API access
/ai-sourceslistPer-assistant AI traffic. verifiedVisitors/verifiedEvents are a subset of the crawler counts, and are null — never 0 — on a backend that could not check signatures.——Any plan with API access
/ai-trendlistDaily human vs AI-assisted visitors across the range.——Any plan with API access
/ai-cubelistAssistant by landing page: sessions, converting sessions and the conversion rate, plus a row per assistant total. `rateBasis` says which rung of the honesty ladder a row earned — `rate` at 100+ sessions, `of` at 20–99 (counts, no percentage), `count` below 20. Assistants with sessions that never reached a page appear with an empty `landingPage`, which is why a total can exceed the sum of its pages.—convertingSessionsconversionRatelandingPageAny plan with API access
/realtime-baselinesummaryWhat is typical for this project at this weekday and time of day: the median sessions per window with the quartiles either side. Ignores from/to — it reads the last four occurrences of the current weekday. Returns an EMPTY list, not zeros, when the project has fewer than three observed weeks: the band has not been earned rather than measured at zero.window—Any plan with API access
/site-healthlistWeb-vitals p75 per page (TTFB, FCP, LCP, CLS) with the measurement count behind each.——Any plan with API access
/click-maplistClicks and dead-space clicks per page. The per-cell grid is deliberately not exposed.——Starter plan and up
/scroll-maplistScroll reach per page: median depth and the share reaching each 10% stop.——Starter plan and up
/revenuesummaryRevenue totals for the range.—conversionToPaidPro plan and up
/funnelslistSaved funnels on this project, with their ids — pass one to /funnel?funnelId=.—lastUsedAtAny plan with API access
/goals-listlistSaved goals with their ids and configured worth. The `goals` dataset above measures them; this one lists them, and is a separate dataset so the shipped `goals` shape never has to change.—valuecurrencytargetAny plan with API access
/segmentslistSaved segments with the filters each one applies.—lastUsedAtAny plan with API access
/saved-viewslistSaved report views: the surface, the view and the parameters each one restores.—lastUsedAtAny plan with API access
/annotationslistNotes on this project's charts inside the range. Personal notes are never returned; machine markers are, and `includeHidden=false` leaves out the ones the charts keep quiet.categorywrittenByincludeHiddenendAtAny plan with API access
/findingslistWhat the engines found: anomalies, trends and segment gaps, with the evidence behind each and where to look in the dashboard.statuskindseveritysampleSizelabelSourcePro plan and up
/caseslistFixes that were shipped against a finding, and what happened to the metric afterwards. `verification` is null until the after-window closes.stateverificationfixShippedAtresolvedAtscopePathtagPro plan and up
/frustrationlistRage clicks, dead clicks and quick exits, by page or by source. A signal, not a verdict — read the counts with the sessions beside them.dimension—Starter plan and up
/journeys-summarylistEntry and exit sessions per page in one table, so a caller can see where visits start and end without two requests.——Any plan with API access

param* is required. Every dataset also accepts the shared parameters below. A field listed under “can be null” means we did not measure this — never zero, and never a verdict. Summing a null as nothing is how a report ends up stating a figure we deliberately withheld.

That is 35 datasets, and the table is generated from the same catalog the API itself is built from — so a dataset cannot ship without appearing here, and a dataset listed here cannot quietly disappear. The count is not typed anywhere on this page; it is the length of that list.

Parameters

Shared by every endpoint. Anything we do not recognise is a 400, never a silent no-op.

Range and paging

  • from, to — an ISO 8601 instant or a bare YYYY-MM-DD, which widens to the whole day. Defaults to the last 30 days. One request may span at most 366 days; read a longer history a window at a time, or use the CSV export.
  • limit — rows per page. Default 100, maximum 1,000.
  • offset — rows to skip. offset + limit must not exceed 5,000.
  • projectId — optional assertion, checked against your key.

Filters — the dashboard's own vocabulary

Spelled exactly as they are on the dashboard, so a filtered screen and a filtered API call return the same number:

Terminal
curl -H "Authorization: Bearer $CORUVE_API_KEY" \
  "https://coruve.com/api/v1/pages?country=DE&channel=ai&utmCampaign=summer&limit=20"

Available on every dataset — 17 of them, listed here from the same constant the dashboard's own chip bar is built from, so this list cannot fall behind the product:

country, region, city, device, browser, os, viewport, channel, source, path, entryPath, exitPath, utmSource, utmMedium, utmCampaign, aiSource, frustration.

A dataset that cannot honour a filter says so in meta.ignored rather than answering a wider question quietly.

A saved segment, by id

?segment=<id> applies a segment you saved in the dashboard. It carries two things the filter vocabulary above cannot spell: the segment's name, which comes back in meta.segment so a script can stamp a row “under the Paying customers segment” rather than an opaque id, and its behavioural conditions — “sessions that started checkout and never bought” — which are computed in the store from a step predicate and have no query-string spelling at all.

Ids are resolved server-side and project-scoped: an id from another project is 400 INVALID_REQUEST, not found-and-then-checked.

You may send both. Where a saved segment and an explicit filter name the same dimension, the explicit filter wins — the same precedence the dashboard has when you apply a segment and then change a chip, and the alternative (ANDing two values of one dimension) is a request that can only ever return nothing. Every dimension where that happened is named in meta.segment.overridden, because silent precedence is the failure that field exists to prevent.

Terminal
# The saved segment says country=DE; the chip says AT. AT wins,
# and meta.segment.overridden comes back as ["country"].
curl -H "Authorization: Bearer $CORUVE_API_KEY" \
  "https://coruve.com/api/v1/pages?segment=seg_abc&country=AT"

Per-dataset parameters

Each of these belongs to the datasets named beside it; the endpoint table above says which ones a given dataset takes, and this is what the values mean. Sending one to a dataset that does not use it is not an error — it comes back named in meta.ignored.

ParameterDatasetAccepted valuesWhat it does
dimension/devicesbrowser · os · device · viewportWhich facet of the device the rows break down by. Defaults to browser; anything else is a 400 rather than a silent fallback. /frustration also declares it, but its getter does not read it — the rows come back by page either way.
level/locationscountry · region · cityHow coarse the geography is. Defaults to country. Region and city are derived from the IP at the moment of the request, which is never stored.
funnelId/funnelan id from /funnelsWhich saved funnel to compute. Required — and discoverable, which is what /funnels exists for, so no id has to be copied out of a browser URL.
direction/journeysfwd · bwdWhether to read forwards from the page chain (what they did next) or backwards (how they arrived). Defaults to forwards.
chain/journeysup to four page paths, comma-separatedThe anchor the journey is read from. Required.
status/findings · /casesthe row's own state, free-formDeliberately not an enum: a state added to the engine should narrow an old client's request, not 400 it.
kind/findingsthe engine's finding kind, free-formWhich family of finding — the same vocabulary the Brief groups by. Free-form for the same reason as status.
severity/findingscritical · warning · noticeAn enum, and applied over the page rather than in the query — so it narrows the rows this page returned, not the ranked list they were drawn from.
state/casesthe case state, free-formWhere a fix has got to. Free-form for the same reason as status.
category/annotationsdeploy · campaign · content · incident · experiment · note · tracking · signalOne category from the fixed vocabulary. Free-form tags are deliberately not a thing, so this list is the whole of it.
writtenBy/annotationsalert · deploy_webhook · incident_webhook · import · case · spike · systemWhich writer left the note. `human` is a person in the dashboard and everything above is a machine. It is writtenBy and not source because source is already the traffic source in the filter vocabulary below — one name, two meanings, decided by which dataset you happened to call.
includeHidden/annotations"true" · "false"The machine markers the charts keep quiet are IN by default over the API, unlike on a chart: a caller asking what happened wants the row saying the corpus changed. Send includeHidden=false to leave them out. It is a string checked against two literals, not a boolean, because a coerced boolean reads "false" as true — which would turn an explicit opt-out into an opt-in.
window/realtime-baseline5 · 30 · 60 (minutes)The live-window width the baseline is measured over. An enum of the three widths the engine has buckets for, not a number: a baseline only means anything against a live count measured over the same width, so a request for 45 is refused rather than quietly served as 5.
The API reads through the same service layer as the dashboard, so your traffic-exclusion rules, retention window and low-sample guards all apply identically. There is no raw mode that would return a different truth.

Responses

One envelope, always: data is an array, meta describes the request. Four meta fields are conditional — pagination, ignored, segment and notice — and each is absent rather than empty when it has nothing to say.

200 OK
{
  "data": [
    { "url": "/pricing", "view_count": 412 },
    { "url": "/", "view_count": 388 }
  ],
  "meta": {
    "dataset": "pages",
    "projectId": "proj_abc",
    "range": { "from": "2026-07-01T00:00:00.000Z", "to": "2026-07-31T23:59:59.999Z" },
    "pagination": { "limit": 2, "offset": 0, "count": 2, "hasMore": true }
  }
}

A summary dataset such as overview, sessions or revenue returns a one-element array and no pagination block — so you never branch on the shape of the envelope.

Every list is a ranked top-N. There is no cheap exact count behind one, so we tell you whether there are more rows rather than inventing a total we would have to guess at. For a complete dump, use the CSV export.

meta.ignored — what we did not use

A parameter we have never heard of is a 400, never a silent no-op. But a parameter that is real and simply does not apply to the dataset you asked for used to be accepted in silence: ?country=DE on /events returned every label on the project, under a filter you believed had been applied.

Those now come back named in meta.ignored. The field is absent when there is nothing to report — it is there to be conspicuous when it appears, and a request that carries one is a request worth fixing.

GET /api/v1/segments?country=DE
{
  "data": [ /* every saved segment — country did not narrow anything */ ],
  "meta": {
    "dataset": "segments",
    "ignored": ["country"]
  }
}

meta.segment — which saved segment was applied

A request that carried ?segment= gets the segment back by name, not just the id it sent. overridden names every dimension where an explicit filter replaced one of the segment's own clauses; it is absent when nothing was overridden.

GET /api/v1/pages?segment=seg_abc&country=AT
{
  "data": [ /* pages, under the segment with country forced to AT */ ],
  "meta": {
    "dataset": "pages",
    "segment": { "id": "seg_abc", "name": "Paying customers", "overridden": ["country"] }
  }
}

meta.notice — an empty list that is a refusal, not a zero

This is the field most worth reading, because ignoring it is how a caller draws exactly the wrong conclusion. An empty data array means two different things and they look identical over the wire: nothing happened, and this question cannot be answered on your data. When it is the second, the envelope says so in meta.notice and data is empty.

/retention is the case that forced the field and the only dataset that produces one today. Coruve's anonymous visitor identifier rotates at UTC midnight, so cross-day retention is measurable only for visitors your site has called identify() on. On a project with too few of them there is no honest cohort grid to return — and a caller charting data.length without reading notice draws a flat line and reports that retention collapsed.

GET /api/v1/retention
{
  "data": [],
  "meta": {
    "dataset": "retention",
    "notice": "Retention requires identified users; see /docs/identify"
  }
}

Branch on the presence of notice before you branch on data.length. Like ignored, it is absent when there is nothing to say — a field present on every response stops being read.

Where a number would lie, we send null

On /ai-sources, verifiedVisitors and verifiedEvents are null — never 0 — when the backend could not check cryptographic signatures at all. Zero would be a measurement (“we looked and nothing was signed”); null is the truth (“we never looked”). Sum the column accordingly.

Errors

One JSON shape and a stable code. Branch on the code, not the message.

403 Forbidden
{
  "error": {
    "code": "PLAN_UPGRADE_REQUIRED",
    "message": "Revenue data requires the Pro plan. This project is on Starter.",
    "docs": "https://coruve.com/docs/api"
  }
}
StatusCodeWhen
400INVALID_REQUESTA parameter is missing, malformed, or not one this API accepts. Unknown parameters are rejected rather than ignored.
401UNAUTHORIZEDNo key, an unknown key, a revoked key, or an ingest key. All four give the identical response on purpose — telling them apart would confirm which keys are real.
403PROJECT_MISMATCHYou passed a projectId that is not the one this key belongs to.
403PLAN_UPGRADE_REQUIREDYour plan does not include the read API, or does not include this particular dataset.
404UNKNOWN_DATASETNo dataset by that name. GET /api/v1/datasets lists what exists.
429RATE_LIMITEDYour plan's requests-per-minute allowance is spent. The response carries Retry-After.
500INTERNAL_ERRORSomething broke on our side. Safe to retry.

Rate limits

Per project, per minute, by plan.

PlanRead APIRequests / minute
FreeNot included—
StarterNot included—
ProIncluded120
GrowthIncluded600

What you get back

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. A 429 also carries Retry-After in seconds.

The allowance is counted per project, not per key. Every key on a project draws on the same number, so the figure above is the one your project actually gets — minting a second key does not buy a second allowance. Keys are still worth splitting per integration, for revocation and for reading lastUsedAt; they just do not multiply your throughput.

Before any key is looked at there is also a per-IP ceiling of 300 requests a minute. A client presenting a valid key will never meet it.

What you can build

Three things the roster now covers end to end, without opening the dashboard once.

A client report, without a warehouse

What we noticed, what was fixed about it, and the numbers underneath — three requests, no joins against anything you have to store yourself.

curl
KEY=rk_live_...
BASE=https://coruve.com/api/v1

curl -H "Authorization: Bearer $KEY" "$BASE/findings?status=new"
curl -H "Authorization: Bearer $KEY" "$BASE/cases?state=verified"
curl -H "Authorization: Bearer $KEY" "$BASE/overview?from=2026-08-01&to=2026-08-31"

From a key to a funnel result, with nothing memorised

The reason /funnels exists. Ask what funnels there are, then ask one of them for its steps — no id copied out of a browser URL.

curl
ID=$(curl -sH "Authorization: Bearer $KEY" "$BASE/funnels" | jq -r '.data[0].id')
curl -H "Authorization: Bearer $KEY" "$BASE/funnel?funnelId=$ID"

A weekly CSV, or a whole zip, with no API at all

The export endpoint is session-authenticated rather than key-authenticated, so it is for a person with a browser rather than a script. report=all hands back every report your plan includes, zipped, with a README that explains the columns. If you would rather it arrived on its own, Settings ▸ Data & export will email it every morning, week or month.

In a browser, signed in
/api/export?projectId=<id>&report=all&from=2026-08-01&to=2026-08-31
/api/export?projectId=<id>&report=pages&format=json

Versioning

What we promise not to change, and what we will keep adding.

The rule

The base path /api/v1 is stable. A change that removes a field, renames a dataset, changes a type, or changes pagination semantics is a v2. It does not ship under this path, whatever the reason.

Additive changes — a new dataset, a new optional field, a new optional parameter — ship under v1 and get a row in the changelog below. So you can write code against this version and know that nothing it reads today will stop being there.

There is no version field on a dataset response, and that is deliberate rather than an omission: the base path already carries the version, and a second copy of it in every envelope is a second thing that can disagree. The one place the version is stated in a body is GET /api/v1/datasets, which answers meta.version along with the roster your plan can read — so a client that wants to assert which version it is talking to asks that endpoint once at startup rather than parsing it out of every report.

Retiring v1 would take 12 months' notice on this page, and every response would carry a Deprecation header with the sunset date long before anything stopped working.

Changelog

  • 2026-08-21
    • /funnels, /goals-list, /segments, /saved-views, /annotations — the ids other endpoints need
    • /findings and /cases — what Coruve noticed, and what happened after a fix
    • /frustration and /journeys-summary — two reports that were dashboard-only
    • meta.ignored — the parameters a dataset did not use, named rather than dropped in silence

SDKs

Thin clients: auth header, query builder, retries with backoff, typed responses.

Both clients live in the Coruve repository and are ready to publish, but neither is published. Until they are, install them from a checkout — or just call the API with your HTTP client of choice; it is a bearer token and a query string, and no request needs more than a handful of the 36 parameters above.

Node · @coruve/sdk

reports.ts
import { CoruveClient, CoruveApiError } from "@coruve/sdk";

const coruve = new CoruveClient({ apiKey: process.env.CORUVE_API_KEY! });

// Top pages, last 30 days
const pages = await coruve.get("pages", { limit: 50 });
console.log(pages.data, pages.meta.pagination);

// A summary dataset still returns an array — one element
const [overview] = (await coruve.get("overview", { from: "2026-07-01", to: "2026-07-31" })).data;

// Filters use the dashboard's vocabulary
const ai = await coruve.get("channels", { channel: "ai", country: "DE" });

// Page through a long list
for await (const rows of coruve.paginate("pages", { limit: 200 })) {
  console.log(rows.length);
}

try {
  await coruve.get("revenue");
} catch (err) {
  if (err instanceof CoruveApiError && err.code === "PLAN_UPGRADE_REQUIRED") {
    // Handle the plan gate — err.code is stable, err.message is not
  }
}

Zero dependencies, Node 18+. 429 and 5xx are retried with exponential backoff, honouring Retry-After; 401, 403, 400 and 404 throw immediately, because they are answers rather than hiccups.

Python · coruve

reports.py
import os
from coruve import CoruveClient, CoruveApiError

coruve = CoruveClient(api_key=os.environ["CORUVE_API_KEY"])

# Top pages, last 30 days
pages = coruve.get("pages", limit=50)
for row in pages.data:
    print(row["url"], row["view_count"])

# `from` is a Python keyword — pass from_ / to_
overview = coruve.get("overview", from_="2026-07-01", to_="2026-07-31")
print(overview.data[0])

# Page through a long list
for rows in coruve.paginate("pages", limit=200):
    print(len(rows), "rows")

try:
    coruve.get("revenue")
except CoruveApiError as err:
    if err.code == "PLAN_UPGRADE_REQUIRED":
        ...  # handle the plan gate

Zero dependencies, Python 3.8+. The transport is urllib behind an injectable seam, so you can route calls through httpx, requests, or your own instrumented session by passing transport=.

curl, if you would rather

Terminal
export CORUVE_API_KEY=rk_live_YOUR_KEY

# What can I read?
curl -s -H "Authorization: Bearer $CORUVE_API_KEY" \
  "https://coruve.com/api/v1/datasets" | jq '.data[] | {name, available}'

# Channels for July, German traffic only
curl -s -H "Authorization: Bearer $CORUVE_API_KEY" \
  "https://coruve.com/api/v1/channels?from=2026-07-01&to=2026-07-31&country=DE" | jq '.data'

# Second page of top pages
curl -s -H "Authorization: Bearer $CORUVE_API_KEY" \
  "https://coruve.com/api/v1/pages?limit=100&offset=100" | jq '.meta.pagination'
Read API | Coruve