API
Read your Pulse analytics programmatically with an API key.
Pulse has a read API for pulling your analytics into a dashboard, a data warehouse, or a scheduled report. It is available on every plan — the difference between plans is how much you can pull, not what you can reach.
The API returns aggregate data only. There is no endpoint that returns individual visitors or sessions, and there never will be — see What the API will never return.
Creating a key
Go to Settings → Organization → API Keys and click New key. You will be asked for:
- Name — so you can recognise it later (
Grafana,warehouse sync) - Role — the key can do exactly what this role can. Roles carrying owner-only permissions cannot be attached to a key.
- Expiry — 30 days, 90 days, or 1 year. There is no "never expires" option.
- Scope — specific sites, or all sites in the workspace
Warning
The key is shown once, when you create it. Pulse stores only a hash of it, so we cannot show it to you again or recover it if it is lost. Copy it straight into your secret manager.
Keys look like this:
pulse_sk_live_udo4raikcyn7fnmlkm6k642rma_rklj2fAuthenticating
Send the key as a bearer token:
curl -H "Authorization: Bearer pulse_sk_live_..." \
"https://pulse-api.ciphera.net/api/public/v1/sites/SITE_ID/export/daily?from=2026-08-01&to=2026-08-07"Warning
Keys are server-side only. Requests carrying a browser Origin header are rejected, and the API sends no CORS headers, so a key cannot be used from browser JavaScript. If you want visitor numbers on a public page, use a shared dashboard instead — a key in frontend code is a key you have published.
Passing the key in the URL (?api_key=...) is rejected with a 400. Keys in query strings leak into browser history, Referer headers and proxy logs.
Response shape
Every endpoint except the two CSV exports returns the same envelope:
{
"data": { },
"meta": { "suppressed": false }
}data is the answer. meta describes the answer — the date range the server actually queried, and whether anything was withheld. It is always present, so you never need to check for it before reading it.
The two export/* endpoints predate this envelope and still return CSV, or a bare array with format=json. Those column and field names are stable and will stay that way — where an export needs to tell you something about the response rather than in it, it uses headers.
Endpoints
| Endpoint | Returns |
|---|---|
GET /me | What this key is and when it expires |
GET /sites | The sites this key can read |
GET /sites/{id}/stats | Totals over a date range, filterable |
GET /sites/{id}/realtime | Who is on the site right now |
GET /sites/{id}/export/daily | One row per day, as CSV |
GET /sites/{id}/export/pages | Top pages, as CSV |
Your key
GET /api/public/v1/meValidates the key and describes it. Use it to check a key works before relying on it, and to find out why one stopped — every authentication failure returns the same 401 on purpose, so a successful /me is the only thing that can tell you an expiry date.
curl -H "Authorization: Bearer $PULSE_API_KEY" \
"https://pulse-api.ciphera.net/api/public/v1/me"{
"data": {
"organization": { "id": "2c1f74ec-...", "name": null },
"key": {
"name": "Grafana dashboard",
"last4": "chju",
"expires_at": "2026-11-05T00:00:00Z",
"scope_all_sites": true,
"site_ids": [],
"last_used_at": "2026-08-07T09:12:44Z"
}
},
"meta": { "suppressed": false }
}last_used_at is null for a key that has never been used, and otherwise reports the previous request — not the one you just made.
Sites
GET /api/public/v1/sitesThe sites this key can read. A key scoped to specific sites sees only those.
{
"data": [
{
"id": "e6a95eb8-8edb-44d4-a4e2-c400aea174a4",
"slug": "ciphera",
"domain": "ciphera.net",
"name": "Ciphera",
"timezone": "Europe/Brussels",
"last_event_at": "2026-08-07T09:14:02Z",
"created_at": "2026-02-11T18:31:00Z"
}
],
"meta": { "suppressed": false }
}last_event_at is null if the site has never received an event — which is the quickest way to tell a quiet site from a broken install.
Paths take the id, not the slug. A slug is yours to rename, and a URL that stops working because someone tidied a name is a URL we should not have asked you to store.
Stats
GET /api/public/v1/sites/{site_id}/statsTotals over a date range. Ask for a range with either period or from/to — sending both is a 400 rather than a guess.
| Parameter | Values |
|---|---|
period | 7d, 30d, month, year. Default 7d. |
from, to | YYYY-MM-DD, inclusive, max 366 days apart |
filter | See Filtering. Repeatable. |
curl -H "Authorization: Bearer $PULSE_API_KEY" \
"https://pulse-api.ciphera.net/api/public/v1/sites/$SITE/stats?period=7d"{
"data": {
"visitors": 1284,
"pageviews": 3401,
"bounce_rate": 62.4,
"avg_duration": 107.2,
"avg_scroll_depth": 51.8,
"avg_visible_duration": 44.1
},
"meta": {
"range": { "from": "2026-08-01", "to": "2026-08-07", "timezone": "Europe/Brussels", "period": "7d" },
"suppressed": false
}
}period is resolved on our side, in the site's timezone, and the dates come back in meta.range. That matters more than it looks: if your script worked out "the last 7 days" itself it would use its timezone, and your numbers would quietly stop matching your dashboard. Read meta.range and you always know which days you got.
Realtime
GET /api/public/v1/sites/{site_id}/realtimeSessions active in the last five minutes, and the pages they are on. Takes an optional limit (default 20, max 100).
{
"data": {
"visitors": 27,
"top_paths": [
{ "path": "/", "visitors": 14 },
{ "path": "/pricing", "visitors": 6 }
]
},
"meta": { "suppressed": true, "min_cell_size": 5, "suppressed_rows": 3, "suppressed_total": 7 }
}Realtime accepts no filters, and never will. A filtered five-minute window stops describing a population and starts describing a person.
The headline visitors count is never withheld. The page breakdown is — see below.
Note that top_paths will not sum to visitors: someone who reads two pages in five minutes is counted on both.
Daily metrics
GET /api/public/v1/sites/{site_id}/export/dailyBoth export endpoints require from and to (YYYY-MM-DD, inclusive) and take an optional format of csv (default) or json. They return raw CSV, not the envelope.
Returns one row per day: date, pageviews, visitors, bounce_rate, avg_duration, avg_scroll_depth, avg_visible_duration.
curl -H "Authorization: Bearer $PULSE_API_KEY" \
"https://pulse-api.ciphera.net/api/public/v1/sites/$SITE/export/daily?from=2026-08-01&to=2026-08-07"date,pageviews,visitors,bounce_rate,avg_duration,avg_scroll_depth,avg_visible_duration
2026-08-01,10,9,88.9,115.3,43.0,23.5
2026-08-02,11,9,77.8,83.5,64.1,29.6A day covering fewer than five visitors has its four per-session metrics withheld — the cells arrive empty (JSON null), because a handful of people's bounce rate and durations are their sessions, not an aggregate. The day's pageviews and visitors counts stay: a whole-site day count is an aggregate. How many days were withheld is reported in headers:
X-Pulse-Min-Cell-Size: 5
X-Pulse-Suppressed-Day-Metrics: 1These are sent on every daily export, including when nothing was withheld, so you can read them rather than guess from their absence. An empty cell means the metric was withheld — it does not mean zero.
Top pages
GET /api/public/v1/sites/{site_id}/export/pagesReturns path and pageviews. Also accepts limit (default 100, max 1000) and filters.
When you filter, rows describing fewer than five visitors are withheld and reported in headers, because CSV has nowhere to put a meta object:
X-Pulse-Min-Cell-Size: 5
X-Pulse-Suppressed-Rows: 3
X-Pulse-Suppressed-Pageviews: 7Those headers are sent whenever a filter is applied, including when nothing was withheld, so you can read them rather than guess from their absence. Without a filter, nothing is withheld — a list of paths describes your site, not your visitors.
curl -H "Authorization: Bearer $PULSE_API_KEY" \
"https://pulse-api.ciphera.net/api/public/v1/sites/$SITE/export/pages?from=2026-08-01&to=2026-08-07&limit=20"path,pageviews
/,48
/blog/open-source-privacy-tools-2026,8
/about,4Filtering
/stats and export/pages accept filters. Repeat the parameter — one filter per filter=:
curl -H "Authorization: Bearer $PULSE_API_KEY" \
"https://pulse-api.ciphera.net/api/public/v1/sites/$SITE/stats?period=30d&filter=country==BE&filter=browser!=Firefox"== matches, != excludes. Repeating one dimension widens it — filter=country==BE&filter=country==NL means either. Different dimensions narrow — the example above is Belgian visitors who were not on Firefox.
Repeating the parameter, rather than joining filters with commas, is deliberate: page paths and referrers contain commas all the time, and a joined list would need you to escape them.
Warning
An earlier build of export/pages accepted a filters=v2:... parameter. That was our dashboard's internal format, and on an API key it allowed combinations narrow enough to describe one person. It now returns 400; use filter= as above.
Filterable dimensions: page, referrer, channel, country, region, city, browser, os, device, screen_resolution, language, timezone, utm_source, utm_medium, utm_campaign, utm_term, utm_content.
Warning
A key may combine at most two dimensions in one query. A third returns 400. The dashboard lets you combine all seventeen, because that is your own staff reading your own screen; an API key is an export channel, and each dimension you add narrows the answer closer to one person.
Withheld numbers
When a query is filtered and fewer than five visitors match, Pulse returns nothing for it:
{
"data": {
"visitors": null,
"pageviews": null,
"bounce_rate": null,
"avg_duration": null,
"avg_scroll_depth": null,
"avg_visible_duration": null
},
"meta": {
"range": { "from": "2026-07-09", "to": "2026-08-07", "timezone": "Europe/Brussels", "period": "30d" },
"suppressed": true,
"min_cell_size": 5
}
}Three things about this are worth knowing before you build against it.
null is not zero. A withheld number is missing, not measured as none. If you write it into a warehouse as 0 your totals will be wrong and nothing will error.
Every metric goes, not just the count. A bounce rate calculated over one session is that session.
Suppressed also covers zero. If suppressed: true meant "between one and four" while a genuine zero came back as 0, you could walk the values of a dimension and learn exactly which of them have somebody behind them. So it means "fewer than five, possibly none", and you cannot tell which.
Unfiltered totals are never withheld, however small — one number describing a whole site is a population, not a person.
Where the result is a list rather than a total, rows below the floor are removed and reported instead of vanishing:
"meta": { "suppressed": true, "min_cell_size": 5, "suppressed_rows": 3, "suppressed_total": 7 }That way the rows you can see still reconcile against the total, and you can tell "no data" from "not shown".
A worked example
Pulling a week of metrics into a file, in Python:
import os, csv, io, requests
KEY = os.environ["PULSE_API_KEY"]
SITE = os.environ["PULSE_SITE_ID"]
r = requests.get(
f"https://pulse-api.ciphera.net/api/public/v1/sites/{SITE}/export/daily",
headers={"Authorization": f"Bearer {KEY}"},
params={"from": "2026-08-01", "to": "2026-08-07", "format": "json"},
timeout=30,
)
r.raise_for_status()
for day in r.json():
print(day["date"], day["pageviews"], day["visitors"])Limits
Requests are priced in units rather than counted, because one call asking for a year is not the same load as one asking for yesterday:
cost = ceil(days / 7) × (1 + dimensions) × shape_weight × (filtered ? 5 : 1)shape_weight is 1 for /me, /sites, /stats and /realtime, and 20 for the two exports. In practice:
| Call | Units |
|---|---|
/me, /sites, /realtime | 1 |
/stats?period=7d | 1 |
/stats?period=30d | 5 |
/stats?period=year | 53 |
/stats?period=7d with one filter | 10 |
| A one-week export | 20 |
| A full-year export | 980 |
A request is priced before it runs, from its parameters. A request we cannot price — a malformed date, an unknown period — is charged as if it asked for the maximum, so that getting it wrong is never the cheap way to ask.
| Plan | Units per month | Max keys |
|---|---|---|
| Hobby | 10,000 | 1 |
| Solo | 100,000 | 2 |
| Team | 500,000 | 5 |
| Business | 2,000,000 | 10 |
Every plan also has a burst ceiling of 60 units per second.
Every response carries your current budget:
X-RateLimit-Limit: 250000
X-RateLimit-Remaining: 249940
X-RateLimit-Reset: 1788220799Exceeding a limit returns 429 with a Retry-After header. The monthly budget resets on the 1st.
To put that in perspective: a Grafana panel polling one week of data every 60 minutes costs about 15,000 units a month.
Errors
Every error uses the same shape:
{
"error": {
"type": "invalid_request",
"code": "date_range_too_large",
"message": "date range cannot exceed 366 days"
}
}| Status | Meaning |
|---|---|
400 | Something about the request is wrong — missing from/to, from after to, a range over 366 days, an unknown format, or a key sent in the URL |
401 | Missing, malformed, unknown, revoked or expired key. All five return the same response on purpose. |
404 | No such site, or this key is not scoped to it. Also deliberately the same response — the API does not confirm whether a site you cannot read exists. |
429 | Rate limited. Check Retry-After. |
503 | The API is busy, or realtime data is momentarily unavailable. Retry. |
Common code values on a 400:
code | Meaning |
|---|---|
conflicting_range | Both period and from/to were sent |
unknown_period | period was not one of 7d, 30d, month, year |
invalid_date_range | Missing, malformed, inverted, or over 366 days |
unknown_filter_dimension | Not a filterable dimension |
too_many_filter_dimensions | More than two dimensions combined |
filters_not_supported_on_realtime | filter sent to /realtime |
browser_origin_not_supported | The request carried an Origin header |
credential_in_query_string | The key was in the URL rather than the header |
Rotating and revoking
Revoking takes effect immediately — anything using that key starts failing at once.
To rotate without downtime, create the replacement first, deploy it, then revoke the old key. The key list shows last used for each key, so a key that has never been used is safe to delete.
Pulse support will never ask you for a key. If you think one has leaked, revoke it — the fastest containment is that it stops working.
What the API will never return
Pulse will never expose individual visitor or session records through the API. Not on a higher plan, not on request. This is a permanent commitment, not a limitation we intend to lift.
The reason is that "anonymous" data stops being anonymous once you can join it. A row carrying a session ID, a timestamp to the second, a city and a screen resolution can be matched against your own server logs or order records — at which point it names a person. We are not going to hand out that join key.
So the API returns counts, not rows about people. Concretely, there is no endpoint for individual sessions, per-event logs, or visitor property values.
What is not here yet
Today the API covers your key, your sites, totals, realtime and the two exports. Time series, breakdowns by country or browser or referrer, goals and funnels are not exposed yet.
If you need one of those, tell us — what people actually ask for decides what gets built next.