# How it works Most of the time you do not need to know what a library does on the wire. The other times, you need to know exactly. This page is for the second case: when a request is going somewhere you did not expect, when a header looks wrong in a proxy log, or when you need an endpoint the package does not wrap and want to borrow its credential. # One module, global state `import umami` gives you a module of functions, not a class. The public functions in `umami` are re-exports from `umami.impl`, which holds the configuration as module globals: the URL base, the login token, the default website id and hostname, the Cloud key and region, and the enabled flag. The `set_*` functions, [login()](../reference/login.md#umami.login), [enable()](../reference/enable.md#umami.enable), and [disable()](../reference/disable.md#umami.disable) write those globals; everything else reads them. That design is why the API reads like a conversation ([set_url_base](../reference/set_url_base.md#umami.set_url_base), [login](../reference/login.md#umami.login), [new_event](../reference/new_event.md#umami.new_event)) rather than a construction (`client = Client(...)`), and it has two consequences: - Configuration is per process. Two web workers each need their own [set_url_base()](../reference/set_url_base.md#umami.set_url_base) and [login()](../reference/login.md#umami.login) at startup. - The setters are not synchronized. Call them once at startup, before threads or an event loop start sending events. The send and query functions only read the globals, so concurrent calls after startup are fine. # Two modes, one code path Every request resolves its URL and headers from the current mode at the moment it is made. This table is the whole routing logic: | | Self-hosted | Umami Cloud | |----|----|----| | Selected by | [set_url_base()](../reference/set_url_base.md#umami.set_url_base) and no Cloud key | [set_cloud_api_key()](../reference/set_cloud_api_key.md#umami.set_cloud_api_key) | | Events (`/api/send`) | `{url_base}/api/send` | `https://cloud.umami.is/api/send` | | Queries | `{url_base}/api/...` | `https://api.umami.is/v1[/region]/...` | | Query credential | `Authorization: Bearer ` from [login()](../reference/login.md#umami.login) | `x-umami-api-key: ` | | Event credential | the Bearer token when you have logged in, which Umami ignores; otherwise none | none | | [verify_token()](../reference/verify_token.md#umami.verify_token) | `POST /api/auth/verify` | `GET /me` | | [heartbeat()](../reference/heartbeat.md#umami.heartbeat) | `GET /api/heartbeat` | `GET /me` with the key | # Two user agents Umami was built to receive data from browsers, so it drops ingestion requests whose user agent looks like a bot, and a Python HTTP library's default user agent looks like a bot. The package therefore puts on a costume for `/api/send` calls: a desktop Chrome user agent, which [new_page_view()](../reference/new_page_view.md#umami.new_page_view) lets you swap for the visitor's real one through the `ua` argument. On query calls there is no filter to get past, so the costume comes off and the package sends an honest user agent of the form `Umami-Client v1.1.0 / Python 3.14.10 / Darwin`, available as `umami.user_agent`. If you would rather Umami accepted any user agent, set `DISABLE_BOT_CHECK=true` in the Umami server's environment. # Requests and responses Each function makes one HTTP request, except [websites()](../reference/websites.md#umami.websites), which requests each page of Umami's paged listing in turn. The sync functions call `httpx2.post()` or `httpx2.get()` directly; the async functions open an `httpx2.AsyncClient()` for the call and close it afterwards. There is no connection pool, no retry, and no batching. Redirects are followed. The timeout is `httpx2`'s default of five seconds and is not configurable through the package. The package does the simplest correct thing on each call and leaves pooling, retries, and queueing to the caller, where they are visible. Responses go through `raise_for_status()`, so any non-2xx status raises `httpx2.HTTPStatusError` with the response attached. The two exceptions are [verify_token()](../reference/verify_token.md#umami.verify_token) and [heartbeat()](../reference/heartbeat.md#umami.heartbeat), which catch everything and return `False`, because they exist to answer a yes or no question and a stack trace is neither. Successful query responses are parsed into Pydantic models from `umami.models`. [websites()](../reference/websites.md#umami.websites) unwraps Umami's paged `{"data": [...]}` envelope, follows the `page` parameter until the reported `count` is reached or a page adds nothing new, and returns one flat list. [website_stats()](../reference/website_stats.md#umami.website_stats) returns [WebsiteStats](../reference/models.WebsiteStats.md#umami.models.WebsiteStats). [active_users()](../reference/active_users.md#umami.active_users) reads the `visitors` key and returns an `int`. Send functions return the response body as a plain dict. # Sync and async parity Each `_async` twin builds the same payload, sends the same headers, hits the same URL, and returns the same value as its sync counterpart. That is not a promise, it is a test: the package's suite drives both twins and compares the request bodies, so a difference between the two is a failing build. The configuration functions are sync only because they do no I/O. # Dates and filters [website_stats()](../reference/website_stats.md#umami.website_stats) converts `start_at` and `end_at` to epoch milliseconds with `datetime.timestamp()`, which interprets naive datetimes in the local timezone. The filter keywords `url` and `host` are sent as `path` and `hostname`, the names Umami adopted in October 2025. The other filters are sent under their own names, and `None` values are omitted. # Beyond the wrapped endpoints The package covers configuration, authentication, sending, listing websites, summary stats, and active users. For reports, sessions, event data, metrics, time series, realtime, teams, users, or anything else in the [Umami API](https://umami.is/docs/api), make the call yourself. The tedious part, getting a credential, is already done: ``` python from datetime import datetime, timedelta import httpx2 as httpx import umami base = 'https://umami.example.com' umami.set_url_base(base) login = umami.login('admin', 'super-secret') end = datetime.now() start = end - timedelta(days=7) resp = httpx.get( f'{base}/api/websites/{website_id}/metrics', params={'startAt': int(start.timestamp() * 1000), 'endAt': int(end.timestamp() * 1000), 'type': 'url'}, headers={'Authorization': f'Bearer {login.token}'}, ) resp.raise_for_status() top_pages = resp.json() ``` On Cloud, use `https://api.umami.is/v1` as the base, drop the `/api` prefix from each path, and send `{'x-umami-api-key': key}` instead of the Bearer header. Two conventions trip people up when calling the API directly. The `GET` stats, sessions, events, and revenue endpoints take `startAt` and `endAt` as Unix milliseconds in the query string. The `POST /api/reports/*` endpoints take `startDate` and `endDate` as ISO 8601 strings inside a `parameters` object in the body. The package hides the first convention for you; it does not touch the second.