HTTP API
The Sumimi API is Umami v2 compatible: the collection endpoints, the authentication flow and the statistics endpoints follow Umami's shape, so existing Umami dashboards and trackers work unchanged. Sumimi is an independent open-source project that implements the Umami v2 HTTP API; no Umami assets are used.
Base URL and authentication
All paths are relative to the server root, for example
https://analytics.example.com.
Statistics and website management require a bearer token:
Authorization: Bearer <jwt>
The endpoints that are deliberately public — and only these — are
POST /api/send, POST /api/collect,
GET /api/heartbeat and GET /sumimi.js. Everything else is
default-deny: without a valid token you get 401.
Status codes and errors
| Code | Meaning |
|---|---|
200 | Success. |
201 | Created (website creation). |
204 | Deleted (website deletion) — no body. |
400 | Malformed request. |
401 | Missing or invalid token. |
404 | No such website or resource. |
409 | A website with that domain already exists. |
500 | Server error. |
Every error has the same JSON body:
{ "error": "invalid username or password", "status": 400 }
Collection
POST /api/send
The Umami v2 collection endpoint. Public, no token required.
curl -X POST https://analytics.example.com/api/send \
-H 'content-type: application/json' \
-d '{
"type": "event",
"website": "3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071",
"payload": {
"url": "/posts/hello",
"hostname": "blog.example.com",
"title": "Hello",
"referrer": "https://news.example.com/",
"language": "en-GB",
"screen": "1920x1080"
}
}'
The type discriminator decides what the hit is:
type | Meaning |
|---|---|
event | A page view when the payload has no name; a custom event when it has one. This is how the Umami v2 tracker behaves. |
screenview | A mobile screen view. |
identify | Attaches data to the current session. |
The website may be given as a top-level website field or inside
payload; it may be a website id or a website domain. A payload with a
name and a data object records a custom event.
Responses: 200 on success, 400 for an invalid body or an unknown website.
POST /api/collect
The legacy (Umami v1) collection endpoint: query parameters instead of a JSON body. Public, no token required. Kept for old trackers.
curl -X POST 'https://analytics.example.com/api/collect?type=event&id=3f2a...&url=/posts/hello&title=Hello&hostname=blog.example.com'
Accepted parameters:
| Parameter | Meaning |
|---|---|
id | The website id. Required. |
type | event, screenview or identify. |
url, referrer, hostname, title, language, screen | The usual payload fields. |
name | Custom event name. |
sessionId | A client-supplied session id. Only accepted if it looks like a UUID or a simple token — never arbitrary user input. |
data | Base64-encoded JSON. Decoded and stored as-is. |
GET /api/heartbeat
Liveness and a few counters. Public, no token required.
curl -s https://analytics.example.com/api/heartbeat
{
"ok": true,
"version": "0.1.0",
"uptimeSeconds": 86400,
"queued": 12,
"dropped": 0,
"websites": 3
}
queued is how many events are waiting in the write buffer; dropped
counts events discarded because the queue overflowed. A non-zero dropped
means the server was overloaded and shed the oldest buffered events — see the
FAQ.
Authentication
POST /api/login
curl -X POST https://analytics.example.com/api/login \
-H 'content-type: application/json' \
-d '{"username":"admin","password":"a-long-passphrase"}'
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": "1b2c3d4e-...",
"username": "admin",
"role": "admin",
"createdAt": "2026-01-15T03:30:00"
}
}
The password is stored as an Argon2id hash and verified in constant time. An unknown username and a wrong password produce the same message, so the endpoint does not reveal which usernames exist. Tokens are JWTs signed with HS256 and are valid for seven days.
POST /api/auth/refresh
Exchange a still-valid token for a fresh one. Send the current token in the
Authorization header.
curl -X POST https://analytics.example.com/api/auth/refresh \
-H "authorization: Bearer $TOKEN"
Returns the same shape as /api/login.
GET /api/me
The authenticated user.
curl -s https://analytics.example.com/api/me \
-H "authorization: Bearer $TOKEN"
{ "id": "1b2c3d4e-...", "username": "admin", "role": "admin" }
Websites
GET /api/websites
List every website, newest first. Requires a token.
curl -s https://analytics.example.com/api/websites \
-H "authorization: Bearer $TOKEN"
[
{
"id": "3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071",
"name": "My Blog",
"domain": "blog.example.com",
"createdAt": "2026-01-15T03:30:00",
"updatedAt": "2026-01-15T03:30:00",
"retentionDays": 365,
"countryHeader": null
}
]
POST /api/websites
curl -X POST https://analytics.example.com/api/websites \
-H 'content-type: application/json' \
-H "authorization: Bearer $TOKEN" \
-d '{"name":"My Blog","domain":"blog.example.com"}'
name is required and must not be empty. domain is optional
and must be unique; a new website inherits the server's default retention window and
country-header setting.
201 Created
{
"id": "3f2a1b4c-...",
"websiteId": "3f2a1b4c-...",
"name": "My Blog",
"domain": "blog.example.com",
"createdAt": "2026-01-15T03:30:00",
"updatedAt": "2026-01-15T03:30:00",
"retentionDays": 365
}
409 if a website with that domain already exists.
GET /api/websites/:id
One website, including its data-retention settings.
curl -s https://analytics.example.com/api/websites/3f2a1b4c-... \
-H "authorization: Bearer $TOKEN"
Returns the same object shape as the list endpoint.
PUT /api/websites/:id
Update a website. Every field is optional; omitted fields keep their current value.
curl -X PUT https://analytics.example.com/api/websites/3f2a1b4c-... \
-H 'content-type: application/json' \
-H "authorization: Bearer $TOKEN" \
-d '{"name":"My Blog","domain":"blog.example.com","retentionDays":90,"countryHeader":"CF-IPCountry"}'
retentionDays is 0 for "keep forever". countryHeader
names the request header that carries the country code; omit it to keep the current
setting. A null value does not clear it — the header stays as it was.
DELETE /api/websites/:id
Delete a website and all of its sessions and events.
curl -X DELETE https://analytics.example.com/api/websites/3f2a1b4c-... \
-H "authorization: Bearer $TOKEN"
204 No Content on success, 404 if there is no such website.
Statistics
All statistics endpoints take the same query parameters and return the same breakdown shape.
Query parameters
| Parameter | Type | Meaning |
|---|---|---|
startAt | unix milliseconds | Start of the window, inclusive. Defaults to 7 days ago. |
endAt | unix milliseconds | End of the window, exclusive. Defaults to now. |
url, referrer, browser, os, device, country | string | Dimension filters. An empty value is ignored. |
limit | integer | Maximum rows for breakdown and list endpoints. Default 50, capped at 1000. |
offset | integer | Rows to skip, for pagination. |
event | string | Filter by event name. Accepted by the events breakdown and the events list. |
query | string | Substring filter on the event data. Accepted by the events list only. |
Reads only see data that has been committed, which happens every
SUMIMI_FLUSH_INTERVAL_SECS seconds (two by default). A page view that was
just collected may therefore take a couple of seconds to show up.
Metric meanings
- pageviews
- Total page views.
- visitors
- Unique visitors, counted with a daily-rotating, non-reversible id. "Unique" means unique per day, not unique forever.
- views
- The number of sessions (visits).
- bounces
- Sessions with exactly one page view.
- visitDuration
- Average session duration, in seconds.
These definitions live in one place: the server and the CLI run the same query code against the same schema, so a metric means exactly the same thing whether you read it from the API or from the command line. The numbers are equal by construction, not by convention.
GET /api/websites/:id/stats
The summary metrics, each with the change against the previous window of the same length.
curl -s 'https://analytics.example.com/api/websites/3f2a1b4c-.../stats?startAt=1767225600000&endAt=1767830400000' \
-H "authorization: Bearer $TOKEN"
{
"pageviews": { "value": 1523, "change": 12.4 },
"visitors": { "value": 842, "change": 8.1 },
"views": { "value": 611, "change": 4.9 },
"bounces": { "value": 233, "change": -2.3 },
"visitDuration":{ "value": 84, "change": 1.2 }
}
GET /api/websites/:id/stats/metrics
The same metrics as /stats, with the period-over-period change reported
as zero. The response shape is identical: every metric is a
{value, change} object.
GET /api/websites/:id/stats/timeseries
Page views over time, bucketed by hour for windows of two days or less and by day for longer ones.
[
{ "name": "2026-01-15T00:00:00Z", "value": 41, "date": "2026-01-15T00:00:00Z" },
{ "name": "2026-01-15T01:00:00Z", "value": 38, "date": "2026-01-15T01:00:00Z" }
]
GET /api/websites/:id/stats/{dimension}
The breakdown endpoints are
pages, referrers, browsers, os,
devices, countries, regions,
cities, languages, screens,
channels, hosts, events,
tags, query-parameters and urls.
The channel is where the visit came from: direct, search, social,
email or ads. It is worked out once, when the event is collected, and stored with
it — so the report does not have to guess it from the referrer every time.
The query parameters are the ones of the visited pages, counted
by name and by value:
[ { "name": "utm_source", "value": "twitter", "count": 12 } ].
curl -s 'https://analytics.example.com/api/websites/3f2a1b4c-.../stats/pages?limit=5' \
-H "authorization: Bearer $TOKEN"
[
{ "name": "/posts/hello", "value": 312, "url": "/posts/hello" },
{ "name": "/posts/world", "value": 254, "url": "/posts/world" }
]
The pages breakdown also returns the URL in a url field; the
timeseries breakdown also returns the bucket start in a
date field. Other breakdowns return name and
value only.
GET /api/websites/:id/sessions
The session log, newest first.
curl -s 'https://analytics.example.com/api/websites/3f2a1b4c-.../sessions?limit=2' \
-H "authorization: Bearer $TOKEN"
[
{
"id": "9f8e7d6c-...",
"websiteId": "3f2a1b4c-...",
"createdAt": "2026-01-15T03:30:00",
"updatedAt": "2026-01-15T03:42:10",
"hostname": "blog.example.com",
"url": "/posts/hello",
"title": "Hello",
"referrer": "https://news.example.com/",
"entryUrl": "/posts/hello",
"browser": "Chrome",
"os": "Linux",
"device": "Desktop",
"country": "DE",
"region": null,
"city": null,
"language": "en-GB",
"screen": "1920x1080",
"views": 3,
"duration": 730
}
]
A session row holds the latest view of a session and is updated in place; events are append-only. There is no IP address field, because no IP address is stored.
GET /api/websites/:id/events
The custom event log, newest first.
[
{
"id": "c1d2e3f4-...",
"websiteId": "3f2a1b4c-...",
"sessionId": "9f8e7d6c-...",
"createdAt": "2026-01-15T03:35:00",
"name": "signup",
"url": "/pricing",
"hostname": "blog.example.com",
"data": "{\"plan\":\"free\"}"
}
]
data is the free-form object you sent. It is stored exactly as supplied,
so it must not contain personal data.
The control plane
Everything an instance keeps that is not analytics: the log, the Search Console import, the saved reports, the assistant's conversations, the teams and the report cache. These live in the instance store — a small database of their own — so they survive an analytics rebuild.
GET /api/logs
What happened in this instance, newest first.
curl -H "authorization: Bearer $TOKEN" \
'http://127.0.0.1:3999/api/logs?kind=import&limit=20'
[
{
"at": "2026-03-01T12:00:00",
"level": "info",
"kind": "import",
"actor": null,
"websiteId": "3f2a1b4c-...",
"message": "imported 500 Search Console rows",
"data": null
}
]
POST /api/gsc/import
Import a Search Console export. All or nothing: the whole file lands in one transaction, so a dump that fails halfway does not leave half a week of rows behind.
curl -X POST http://127.0.0.1:3999/api/gsc/import \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"website": "3f2a1b4c-...",
"source": "gsc.csv",
"rows": [
{ "date": "2026-03-01", "query": "sumimi", "page": "https://example.com/",
"clicks": 42, "impressions": 3100, "ctr": 0.0135, "position": 3.2 }
]
}'
{ "id": 1, "rows": 1, "at": "2026-03-02T09:00:00" }
The command line does the same thing, and reads the CSV file itself:
sumimi gsc import gsc.csv --website <id>.
GET /api/websites/:id/gsc
The imported rows, newest day first.
[
{
"date": "2026-03-01",
"query": "sumimi",
"page": "https://example.com/",
"clicks": 42,
"impressions": 3100,
"ctr": 0.0135,
"position": 3.2
}
]
GET /api/websites/:id/gsc/imports
What was imported, and when.
[{ "id": 1, "rows": 500, "at": "2026-03-02T09:00:00" }]
POST /api/snapshots
Save a report under a name. The report is run now, and both the question and the answer are stored — so a saved report is a saved question, not a saved number.
curl -X POST http://127.0.0.1:3999/api/snapshots \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name": "monday",
"spec": { "website": "3f2a1b4c-...", "report": "summary",
"startAt": 1772323200000, "endAt": 1772409600000 }
}'
report is one of summary, timeseries,
pages, referrers, browsers,
os, devices, countries,
languages, screens, events,
sessions or eventLog.
POST /api/snapshots/:name/run
Run a saved report again: the stored question is asked against the data as it is now, and the answer replaces the old one.
DELETE /api/snapshots/:name
Forget a saved report. 204 when it was there, 404 when it was not.
POST /api/chat/sessions
The assistant's conversations. GET lists them, POST
starts one; GET /api/chat/sessions/:id returns its messages, oldest
first, and POST /api/chat/sessions/:id/messages adds one.
POST /api/chat/sessions/:id/ask
Ask a question, get an answer. The question is turned into a report — the same report the dashboard asks for — the report is run, and the answer is written from the numbers that came back. So the answer cannot disagree with the dashboard, it cannot invent a number, and it needs no API key: nothing leaves the box.
curl -X POST http://127.0.0.1:3999/api/chat/sessions/9f1c.../ask \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{ "question": "why did traffic drop last week?" }'
{
"message": {
"id": 12,
"role": "assistant",
"content": "Over the last 7 days: 1,204 page views. That is -12.4% against the previous 7 days. The biggest movers: /pricing (-180), /docs (-60).",
"data": "{ ... the numbers the answer was made of ... }"
},
"facts": { "metric": "pageviews", "summary": { ... }, "movers": [ ... ] },
"report": "summary"
}
The questions it understands are the ones a site owner asks: how many visitors
or page views, over which window (last 30 days, this week,
最近 30 天), broken down by pages, referrers, browsers, devices or
countries, and compared with the period before. Questions in Chinese are answered
in Chinese.
POST /api/websites/:id/ask
The same assistant, without the conversation: one question, one answer, nothing written down. It is what the MCP server calls.
curl -X POST http://127.0.0.1:3999/api/websites/3f2a.../ask \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{ "question": "how many visitors in the last 30 days?" }'
{
"text": "Over the last 30 days: 1,204 visitors.",
"facts": { "metric": "visitors", "summary": { ... } },
"report": "summary"
}
The website may be named by its id or by its domain.
POST /api/teams
The teams of this instance. GET lists them, POST creates
one; GET /api/teams/:id returns one and DELETE removes it
along with its memberships.
curl -X POST http://127.0.0.1:3999/api/teams \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{ "name": "Marketing" }'
POST /api/teams/:id/members
The members of a team. GET lists them, POST adds one or
changes their role, and DELETE /api/teams/:id/members/:username removes
one. The role is owner, member or viewer.
The team role lives in the membership; the account keeps its instance role. Putting an account in a team never takes its instance rights away, and taking it out never gives it any back.
curl -X POST http://127.0.0.1:3999/api/teams/7a2e.../members \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{ "username": "alice", "role": "viewer" }'
DELETE /api/cache
The report cache. GET says how many answers it holds,
DELETE empties it.
The cache carries the version of the data it was computed from and the moment it
was computed. While the version still matches, the answer is exactly right.
After a write the answer may still be used for as long as the stale window
allows — SUMIMI_CACHE_STALE_SECS, 30 seconds by default,
0 for "only while the data has not changed". The window is what
makes the cache worth having: the writer commits every couple of seconds, so a
cache that only accepted the current version would never hit on a busy site.
POST /api/websites/:id/uptime/check
Check every endpoint of a website — its own domain and each of the extra ones — and record the answers.
A failed check also asks two references (bing.com and
8.8.8.8:53), so "the site is down" and "this machine has no
internet" are never confused.
curl -X POST http://127.0.0.1:3999/api/websites/3f2a.../uptime/check \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{ "timeout": 10 }'
POST /api/websites/:id/uptime/endpoints
The extra endpoints of a website — the health check, the login page, the API. The website's own domain is always watched; these are the others.
curl -X POST http://127.0.0.1:3999/api/websites/3f2a.../uptime/endpoints \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{ "url": "https://example.com/health" }'
GET lists them, DELETE stops watching one
(the URL goes in the query string:
?url=https%3A%2F%2Fexample.com%2Fhealth).
Static files
| Path | What it serves |
|---|---|
GET / | The dashboard shell — a small HTML document that loads the frontend JavaScript. |
GET /sumimi.js | The tracker. |
GET /app/{path} | The dashboard frontend files, from SUMIMI_JS_DIR. |
GET /docs | This documentation site, from SUMIMI_DOCS_DIR. |
GET /docs/{path} | The individual pages of the documentation site. |
A complete example
Log in, create a website, send a hit and read the statistics back — end to end:
# 1. log in
TOKEN=$(curl -s -X POST http://127.0.0.1:3999/api/login \
-H 'content-type: application/json' \
-d '{"username":"admin","password":"a-long-passphrase"}' \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
# 2. create a website
SITE=$(curl -s -X POST http://127.0.0.1:3999/api/websites \
-H 'content-type: application/json' \
-H "authorization: Bearer $TOKEN" \
-d '{"name":"My Blog","domain":"blog.example.com"}' \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
# 3. send a page view
curl -s -X POST http://127.0.0.1:3999/api/send \
-H 'content-type: application/json' \
-d "{\"type\":\"event\",\"website\":\"$SITE\",\"payload\":{\"url\":\"/hello\"}}"
# 4. read the numbers
sleep 3
curl -s "http://127.0.0.1:3999/api/websites/$SITE/stats" \
-H "authorization: Bearer $TOKEN"
Sumimi is an independent open-source project, an open-source alternative to Umami that implements the Umami v2 HTTP API. Apache-2.0 licensed.