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

CodeMeaning
200Success.
201Created (website creation).
204Deleted (website deletion) — no body.
400Malformed request.
401Missing or invalid token.
404No such website or resource.
409A website with that domain already exists.
500Server 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:

typeMeaning
eventA page view when the payload has no name; a custom event when it has one. This is how the Umami v2 tracker behaves.
screenviewA mobile screen view.
identifyAttaches 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:

ParameterMeaning
idThe website id. Required.
typeevent, screenview or identify.
url, referrer, hostname, title, language, screenThe usual payload fields.
nameCustom event name.
sessionIdA client-supplied session id. Only accepted if it looks like a UUID or a simple token — never arbitrary user input.
dataBase64-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

ParameterTypeMeaning
startAtunix millisecondsStart of the window, inclusive. Defaults to 7 days ago.
endAtunix millisecondsEnd of the window, exclusive. Defaults to now.
url, referrer, browser, os, device, countrystringDimension filters. An empty value is ignored.
limitintegerMaximum rows for breakdown and list endpoints. Default 50, capped at 1000.
offsetintegerRows to skip, for pagination.
eventstringFilter by event name. Accepted by the events breakdown and the events list.
querystringSubstring 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

PathWhat it serves
GET /The dashboard shell — a small HTML document that loads the frontend JavaScript.
GET /sumimi.jsThe tracker.
GET /app/{path}The dashboard frontend files, from SUMIMI_JS_DIR.
GET /docsThis 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.