# Read API

Read overview, timeseries, breakdowns, revenue, profit, cohorts and path to purchase with a read-scoped key, the same numbers as the dashboard.

The Read API returns the numbers behind the dashboard: traffic, revenue, profit, cohorts and path to purchase. It uses the same queries as the dashboard, so a figure from the API matches the figure on screen for the same range and filters.

## Authentication

Create a key with the `read` scope in Settings → API keys, then send it as a Bearer token. Read keys are on the Starter plan and above. See [Authentication and API keys](/docs/api/authentication).

```bash
curl "https://api.productlense.app/api/v1/stats/overview?site_id=SITE_ID&from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $PL_READ_KEY"
```

A read key can read; it cannot send events or payments, and it cannot change anything.

## Endpoints

| Endpoint | Returns |
|---|---|
| `GET /api/v1/stats/overview` | Visitors, pageviews, sessions, bounce rate, average duration, signups, customers, revenue, fees, net revenue, spend, profit, CAC, ROAS, conversion rate, revenue per visitor and profit per visitor |
| `GET /api/v1/stats/timeseries` | Points `{ t, v }` for one metric: `metric=visitors`, `pageviews`, `revenue_usd`, `profit_usd` or `signups`, with `interval=hour`, `day`, `week` or `month` |
| `GET /api/v1/stats/breakdown` | Rows for one dimension: `dim=channel`, `source`, `medium`, `campaign`, `term`, `content`, `ref_domain`, `path`, `entry_path`, `exit_path`, `hostname`, `country`, `region`, `city`, `device`, `browser`, `os` or `event`, with `limit` and `offset` |
| `GET /api/v1/stats/revenue` | MRR, new, expansion and churned MRR, refunds, revenue, fees, net revenue, customers, ARPU and LTV |
| `GET /api/v1/stats/profit` | One row per channel, source, medium and campaign with customers, revenue, fees, net revenue, spend, profit, CAC, ROAS and payback |
| `GET /api/v1/stats/cohorts` | Net revenue retention cohorts, `by=channel`, `source` or `campaign` |
| `GET /api/v1/stats/purchase-path` | Days and visits to purchase, a weekday × hour heatmap of payments and revenue by device. |

`profit`, `cohorts` and `purchase-path` need a plan that includes those reports (Growth and above); on a smaller plan they answer `403` with the code `plan_required`.

## Parameters

| Parameter | Required | Meaning |
|---|---|---|
| `site_id` | yes | The site id from Settings → Sites. The key must belong to this site. |
| `from`, `to` | yes | Dates as `YYYY-MM-DD`, inclusive, within your plan's history window |
| `tz` | no | An IANA time zone; defaults to the site's reporting time zone |
| `filters` | no | `key:operator:value` triples separated by commas, such as `channel:is:paid,path:contains:/blog`. |

## Responses

Every response uses one envelope:

```json
{ "ok": true, "data": { "visitors": 18240, "revenue_usd": 2407, "fees_usd": 152, "spend_usd": 1518, "profit_usd": 737 }, "meta": {} }
```

Amounts are USD at the payment date's ECB rate, with the definitions in [How ProductLense counts](/docs/get-started/how-productlense-counts#money-metrics). Unique visitor counts carry the approximation stated there. Errors use the shape on [Errors](/docs/api/errors).

## Rate limit

Each key can make 120 requests a minute. Past that, the API answers `429` with the code `rate_limited` and a `Retry-After` header in seconds.

## Example

```js tab="Node"
const params = new URLSearchParams({ site_id: 'SITE_ID', from: '2026-09-01', to: '2026-09-30', dim: 'channel', limit: '10' });
const res = await fetch(`https://api.productlense.app/api/v1/stats/breakdown?${params}`, {
  headers: { Authorization: `Bearer ${process.env.PL_READ_KEY}` },
});
const { ok, data, error } = await res.json();
if (!ok) throw new Error(`${error.code}: ${error.message}`);
```

```python tab="Python"
import os
import requests

res = requests.get(
    "https://api.productlense.app/api/v1/stats/breakdown",
    params={"site_id": "SITE_ID", "from": "2026-09-01", "to": "2026-09-30", "dim": "channel", "limit": 10},
    headers={"Authorization": f"Bearer {os.environ['PL_READ_KEY']}"},
    timeout=10,
)
body = res.json()
if not body["ok"]:
    raise RuntimeError(body["error"]["code"])
```

To ask questions in plain language from Claude, Cursor or ChatGPT instead, see [Connect an AI assistant](/docs/api/mcp).
