---
title: "Ingest a web analytics beat"
method: POST
path: "/track"
tags: ["Web Analytics"]
---

# Ingest a web analytics beat

`POST /track`

Public collect endpoint used by the Notifuse Analytics browser SDK. Each beat carries the full cumulative session state (all pageviews and goals), so the server can rebuild the session from any single payload. Beats are typically sent as text/plain to avoid CORS preflights; the body is JSON regardless of Content-Type. Silently-dropped traffic (disabled feature, disallowed origin, bot user agents) still receives success responses.

The endpoint is public and unauthenticated, so the identity fields (contact_email + contact_email_hmac, or identify_token) are claims until they verify against the workspace secret. One that does not verify is not an error: the beat is recorded anonymously. A single malformed action is dropped the same way, leaving the rest of the beat intact.

Verifying the credential is the FIRST of three gates, not the only one. An address that clears all three is attached to the session; failing any of them costs the identity alone, silently — the beat is still stored and the response is still a 200 with no error. Success and every failure are therefore indistinguishable on the wire:

1. Signature (or token decryption) must verify against the workspace secret. The recipe is in the contact_email_hmac schema; getting the domain-separation prefix wrong is the usual cause.

2. Rate limits, applied to the IDENTIFIED path only (anonymous traffic is the normal firehose and stays unthrottled) and before the contact lookup, so an abusive caller cannot spend database reads: 120 identified beats per minute per workspace + address, and 600 per minute per client IP (that one counted across all workspaces, since an office shares one IP) — both sized around the SDK's 10-30s heartbeat. Exceeding either is deliberately never a 429, which the SDK would only queue for a retry that cannot fix it.

3. The address must ALREADY be a contact in the workspace. A signature proves who the caller is, never that the address belongs to anyone, so without this gate a workspace's own signing key could store the email of people who are not contacts, and erasure would be unenforceable — a deleted contact's next beat would re-stamp the address. The answer is cached for 60 seconds, so a freshly created contact can stay unidentified and a deleted one keep resolving for about that long.

This is why a correctly-signed beat for an address that is not a contact is indistinguishable from a bad signature: both return 200 and record the session anonymously.

## Request body

- WebTrackPayload
  - `workspace_id` string, required
  - `session_id` string, required — Client-generated UUIDv7. Its embedded timestamp is the single source of truth for the session start and for the partition the rows land in, and must fall within [now-48h, now+24h]. The past bound is the wider one because a session can keep beating for 24h after it started and the SDK offline queue holds beats for another 24h; the future bound is the same 24h as the other timestamps, since the id is minted from the device clock and a visitor whose clock runs fast inherits the whole skew.
  - `actions` union[], required — The FULL cumulative list of session actions (re-sent on every beat). Two rules apply here and they are easy to conflate. One ACTION that breaks its own bounds is DROPPED on its own and the rest of the beat is still recorded — never a 400, because the list is cumulative and rejecting the beat would reject every later beat of that session too, permanently. The LIST exceeding maxItems is the opposite: it is a 400 for the whole beat, and because actions[] only ever grows, every later beat fails the same way until the client trims its oldest actions or rotates the session — the same remedy as the 413.
    - union
      - WebPageviewAction — One pageview of the cumulative action list. Breaking any bound below drops this action alone; the rest of the beat is still accepted.
        - `type` 'pageview', required
        - `path` string, required
        - `page_number` integer, required
        - `duration` integer — Engaged (visible and focused) time on the page, ms. Capped at a day, which is more engaged time than one page can plausibly collect.
        - `scroll` integer
        - `entered_at` integer — Epoch ms, bounded to what a timestamp column can hold rather than to a window: a replayed offline beat legitimately carries an old timestamp.
        - `exited_at` integer — Epoch ms; when both are set, must be >= entered_at.
      - WebGoalAction — One conversion of the cumulative action list. Breaking any bound below drops this action alone; the rest of the beat is still accepted.
        - `type` 'goal', required
        - `name` string, required
        - `path` string, required
        - `page_number` integer, required
        - `timestamp` integer, required — Original client timestamp in epoch ms; part of the goal's dedup key, so a retried beat lands on the same row. Bounded to what a timestamp column can hold, not to a window.
        - `value` number
        - `properties` object — At most 50 keys, 1024 characters per value and 8 KB in total: the action list is cumulative, so an unbounded map is re-sent forever and would eventually push the body past the 1 MB limit.
  - `attributes` WebSessionAttributes
    - `referrer` string
    - `landing_page` string, required
    - `utm_source` string
    - `utm_medium` string
    - `utm_campaign` string
    - `utm_term` string
    - `utm_content` string
    - `utm_id` string
    - `utm_id_from` string
    - `screen_width` integer
    - `screen_height` integer
    - `viewport_width` integer
    - `viewport_height` integer
    - `device` string — Device class, one of desktop, mobile or tablet. Detected IN THE BROWSER by the SDK, not re-parsed from user_agent server-side: modern browsers freeze the UA string and expose the real device, OS and version only through the Client Hints API, which never reaches the server. Client input, so it is trimmed to 200 characters and stored as sent. Defaults to "desktop" when absent or blank.
    - `browser` string — Browser name from the same in-browser detection ("Chrome", "Safari", …). Defaults to "Unknown" when absent or blank.
    - `browser_type` string — Qualifier for non-ordinary clients the SDK recognises — crawler, inapp, email, fetcher, cli — and empty for a normal browser. Note the server's own bot filtering works off the request User-Agent header, not this field, which is client input like the rest.
    - `os` string — Operating system from the same in-browser detection ("macOS", "Windows", "iPadOS", …). Defaults to "Unknown" when absent or blank.
    - `user_agent` string — Raw user agent, stored as sent and usable as an attribution filter dimension. It is NOT parsed into device/browser/os — those arrive already resolved in the fields above. Falls back to the request's User-Agent header when the attribute is absent.
    - `connection_type` string
    - `language` string
    - `timezone` string
  - `created_at` integer — Session creation, epoch ms. Optional, and never read: the session start comes from the UUIDv7 session_id instead, so a stored session start can never disagree with the partition its rows landed in. Accepted for wire compatibility with SDKs that still send it, discarded on arrival — not listed as required, so a generated client is not forced to invent a value the server throws away.
  - `updated_at` integer, required — Beat timestamp, epoch ms; must be within 24h of server time.
  - `sdk_version` string
  - `tab_id` integer — The writing tab. Tabs share a session id but keep their own cumulative actions and their own seq, so they are disjoint writers. Absent (or 0) from an older SDK.
  - `sent_at` integer — Stamped at each HTTP attempt; drives clock-skew correction beyond 5s.
  - `contact_email` string — Contact address claimed for this session, signed by contact_email_hmac. /track is public and unauthenticated, so no identity field is believed until it verifies against the workspace secret. Send either this pair or identify_token, never both. A valid signature is necessary but NOT sufficient: the identified path is rate limited, and the address must already be a contact in the workspace. Both extra gates are described under the /track operation. Every rejection along the way — bad signature, throttled, unknown address — costs the identity only: the beat is still recorded, just anonymously, and the response is the same 200 with no error. There is nothing on the wire distinguishing a working integration from a broken one, so verify against a contact that exists and check that the session actually shows up identified.
  - `contact_email_hmac` string — HMAC-SHA-256 of a DOMAIN-SEPARATED string, minted on the customer's server (never in the browser, which would hand the workspace secret to every visitor). The exact recipe, which must be followed literally: signature = lowercase_hex( HMAC_SHA256( key = workspace secret key, message = "wa_identify:" + contact_email ) ) The message is the ASCII prefix "wa_identify:" concatenated with the address — signing bare "alice@example.com" instead of "wa_identify:alice@example.com" is the failure mode to check first. The result is the 64 lowercase hex characters of the digest, compared as bytes, so uppercase hex or base64 will not match. The prefix exists to keep this credential from being interchangeable with the email HMAC that authorizes subscription changes (notification center, unsubscribe, one-click), which is computed over the bare address with the same workspace secret and is printed into every email Notifuse sends. Without the prefix, an unsubscribe HMAC scraped from a forwarded email would silently identify a visitor, and this analytics credential lifted out of page JS by any third-party script would let its holder change that contact's subscriptions. Signed and verified over the RAW address exactly as it appears in contact_email, before any normalization: sign the same bytes you send. Normalization (trim, lowercase) happens only after the signature checks out, to match the stored contact.
  - `identify_token` string — Opaque encrypted credential carried by an email-click link (the nf_id parameter), and the alternative to the contact_email / contact_email_hmac pair rather than a companion to it. Expired or undecryptable tokens cost the identity only, not the beat. Once decrypted, the address it carries passes through the same two further gates as a signed contact_email (rate limit, then contact-must-exist). The length limit is a ceiling on what the server will look at, not a size to aim for: it is derived from the longest address contacts can store, so that no token Notifuse mints can ever exceed it. Send the nf_id value back exactly as it arrived — a longer one is discarded before decryption, which costs the identity silently.
  - `dimensions` object — Custom dimensions custom_1..custom_10.
  - `seq` integer — Monotonic per-session beat counter; the server only applies strictly newer beats.

## Response `200`

Beat accepted (or silently dropped)

- object
  - `success` boolean

## Other responses

- `400` — Malformed payload. The full set of causes: an unparseable body; a missing or blank workspace_id; a session_id that is not a UUIDv7 or whose embedded timestamp falls outside [now-48h, now+24h]; an updated_at more than 24h from server time; a negative seq; more actions than the schema's maxItems; a custom_1..custom_10 dimension value over 256 characters; or more than 50 dimension entries. Row building re-derives the session date from session_id and surfaces any failure the same way, which today can only be that same session_id rule. Never returned for an unverifiable, throttled or unknown identity, and never for a single out-of-range ACTION — that one action is dropped and the beat is kept. An oversized actions ARRAY is a different rule and IS a 400: see maxItems on the actions property.
- `413` — Body over 1 MB. Distinguished from a 400 because it is the one failure the client can act on: actions[] only grows, so every later beat of that session fails too until it trims its oldest actions or rotates the session.

## Changes

- **2026-08-17** `d26605f2fc47` — 1 info
  - endpoint added

[Change history](https://skmtc.dev/pierre-b/apis/notifuse-api/changes/track/post.md)

---

[API](https://skmtc.dev/pierre-b/apis/notifuse-api.md) · [All operations](https://skmtc.dev/pierre-b/apis/notifuse-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/pierre-b/notifuse-api/revisions/d26605f2fc47/schema)
