Skip to main content
How 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 searches nonce = 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:
All events of one page load carry the same proof. What the gateway checks, and in which order, is in section 4.4 of the plan and in browser-proof.md. With data-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.