sdk.js talks to the gateway at in.<BASE_DOMAIN>. The TypeScript source of truth is
packages/tracker/src/wire.ts; internal/events/wire.go mirrors it. A change to either needs the
same change in the other and in this page.
Endpoints
Both
/v1/c and /v1/e are CORS simple requests: the tracker sends no custom headers and uses
credentials: 'omit', so the browser sends no preflight and no cookies. The gateway answers both
with Access-Control-Allow-Origin (the request’s origin when it is registered for the site).
Challenge
GET /v1/c?w=<site id> answers 200 with JSON:
tagInput is the UTF-8 string w|iat|salt|bits, with iat and bits in decimal and salt in the
hex form above. proof_key is the daily key in the edge_secrets bucket.
The tracker refuses a challenge whose w differs from its site id, or with any field outside the
types above, or with bits above 24 (MAX_PROOF_BITS). It then sends nothing and asks again on the
next event. The challenge is kept in memory only. The tracker fetches a new one after 5 hours of
local monotonic time, inside the gateway’s 6-hour validity.
A single-page app keeps one page load for as long as the tab is open, while the gateway accepts
one proof for PROOF_REPLAY_LIMIT queued events per node (50). From release 1.2.0 the tracker
fetches a new challenge after 40 events (PROOF_MAX_USES in wire.ts), and when an event is
answered 403 with rejected_replay, rejected_challenge_expired or rejected_challenge_tag
it forgets the proof and sends that event once more with a new one. Releases 1.0.0 and 1.1.0
keep one proof per page load and lose the events of a long session after the 50th per node.
Proof
The tracker searchesnonce = 0, 1, 2, ... and takes the smallest nonce for which
SHA-256(proofInput) has at least bits leading zero bits, counted from the most significant bit
of the first byte. proofInput is the UTF-8 string w|iat|salt|nonce, all decimal except salt.
None of the parts can contain |.
Test vectors, with the proof key, tag, nonce and digest, are in
packages/tracker/test/vectors/proof-v1.json. The Go side and the e2e recorder build their
challenges from the same file.
Event
POST /v1/e, Content-Type: text/plain;charset=UTF-8, body a JSON object of at most 8192 bytes,
sent with fetch and keepalive: true.
Fragments. A fragment can carry secrets, for example an OAuth access token after an implicit
grant redirect (
/callback#access_token=...), so it is never sent by default. It is sent only when
it starts with #/ (hash-based SPA routing) and data-exclude-hash is not set, and then without any
query part inside it: /app#/users/7?tab=2 is sent as /app#/users/7. Every other fragment
(#section, #access_token=..., #) is dropped. The same rule applies to the url a
data-before-send hook or umami.track(props) supplies. Because pageviews compare urls after this
rule, a jump to an in-page anchor is not counted as a pageview, while a change of hash route is.
Referrer. The tracker sends what the browser gives it (r above), but the gateway keeps only
the scheme and host of a referrer from another origin before the event is queued:
https://news.example/story/42?utm_campaign=x#top is stored as https://news.example. The port,
user info, path, query and fragment are dropped, the host is lower cased and redacted like every
other field, and a same-origin path, a scheme-relative url, an opaque url (mailto:) or anything
that does not parse becomes empty. CNIL’s self-assessment tool for consent-exempt audience
measurement lists a referrer limited to the host as its measure against importing outside data
(docs/compliance/sources.md, S2), and the referrers metric needs only the host
(internal/gateway/sanitize.go, SanitizeReferrer).
Limits are in UTF-16 code units, as JavaScript counts string length. The tracker cuts h, u,
r, ti, s, l and n to their limits (never splitting a surrogate pair), so automatic fields
never make an event invalid. Unknown fields are not sent.
Event data is a flat object: at most 16 keys, each 1 to 50 characters, each value a string (cut
to 500 characters, no nul character), a finite number or a boolean. Values Umami 3.5.0 cannot
store are refused as well (tracker from 1.2.0 and gateway; cases in
packages/tracker/test/vectors/storable-v1.json): a number of 1e15 or more in absolute value
(event_data.number_value is numeric(19,4)), a revenue of 1e14 or more when currency is
a non-blank string (Umami’s own check), and a string in Umami’s date format
(YYYY-MM-DDTHH:MM:SS, optional .mmm, Z or +HH:MM) dated year 0000 or 0001-01-01, which the
timestamp column refuses. An event name may not start with =, +, - or @, which Umami
refuses as spreadsheet formula triggers. The tracker refuses anything
else: null, nested objects, arrays, undefined, NaN, too many keys, too long keys, keys with
control characters, and a body over 8192 bytes. An event name with control characters is refused
the same way. In
that case umami.track() returns a rejected promise and nothing is sent, so the mistake shows in
the site’s console instead of disappearing.
Example:
Consent-gated start
Withdata-auto-track="false" the tracker loads but sends nothing and fetches no challenge. Once
the visitor has consented, the site calls umami.start(), which sends the pageview for the current
page and turns on SPA navigation tracking and data-umami-event click capture, exactly as automatic
tracking would have. A second call does nothing. umami.track() also works before start(), for a
site that wants to send only chosen events. Sites in jurisdictions that need opt-in use this; the
default, without the attribute, is to start at once.
Differences from Umami’s /api/send
Removed: data-tag, identify, distinct ids, client salts, web vitals, error capture, the
credentials option, recorder hooks and the x-umami-cache header.