Ingest a web analytics beat
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:
-
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.
-
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.
-
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.
Body
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.
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.
1000One pageview of the cumulative action list. Breaking any bound below drops this action alone; the rest of the beat is still accepted.
- Option 1
- Option 2
Beat timestamp, epoch ms; must be within 24h of server time.
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.
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.
Stamped at each HTTP attempt; drives clock-skew correction beyond 5s.
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.
255HMAC-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.
64Opaque 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.
3174Custom dimensions custom_1..custom_10.
Monotonic per-session beat counter; the server only applies strictly newer beats.
x >= 0Response
Beat accepted (or silently dropped)
