Create Analytics Session
Public session-start endpoint. GDPR consent gate, server-side UA + GeoIP, hashed visitorId, HMAC tracking-token requirement when bioPageId is present. Returns null when bot / no consent / forged token. Writes to the separate Analytics DB.
POST /api/v1/analytics/session โ ๐ Public ยท Rate limit: 10 req / minute
Opens a new analytics session โ the parent record for subsequent pageviews and heartbeats. Public so the SSR shell can call it before any login. The server augments the request with server-side UA parsing and GeoIP lookup, hashes the supplied visitorId, then writes the row to the separate Analytics DB.
This endpoint silently rejects four kinds of requests with 200 { id: null }: (1) bot user-agents (UA matched against a regex of common crawler tokens), (2) requests where consentAnalytics !== 'granted' (GDPR gate), (3) requests with bioPageId but no valid HMAC tracking token, and (4) DB write failures (caught, logged, swallowed). Always check data.id โ null means "tracking did not happen, don't bother sending pageviews against this session."
HMAC tracking token required when bioPageId is set. The token is generated server-side as HMAC-SHA256(TRACKING_HMAC_SECRET, '<bioPageId>:<5min-bucket>')[:32], embedded in the SSR-rendered bio page, and validated against the current and previous 5-minute bucket (10-min validity). There is no public endpoint to obtain this token โ it lives in the SSR HTML. Custom integrations that don't render through the platform SSR should omit bioPageId (you can still track the session, but it won't be linked to a bio page).
Server-side UA + GeoIP win over client-supplied values. Client may send device, browser, os, osVersion, timezone โ but the server runs UAParser on the request User-Agent header and overrides those fields when it can parse them. GeoIP is derived from the request IP (cf-connecting-ip > x-forwarded-for[0] > req.ip). The IP itself is never persisted โ only the derived country / city / region / continent / timezone columns land in the row.
visitorId is one-way hashed before write. Client supplies its anonymous localStorage UUID; server runs SHA256(visitorId) and stores the hash (per GDPR Article 4(5) on pseudonymization). Joins across sessions still work because the hash is deterministic, but the original visitorId never reaches the DB.
Request
Body โ CreateSessionDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
visitorId | string | โ | IsString | Anonymous client-side UUID (e.g. from localStorage). Hashed before write. |
landingPage | string | โ | IsString | First URL the user landed on (full URL). |
consentAnalytics | string | โ for tracking | IsString MaxLength(50) | Must be 'granted' for the row to land. Anything else โ 200 { id: null }. |
bioPageId | string (UUID) | optional | IsString | When set, also requires a valid trackingToken โ otherwise the session is rejected. |
creatorId | string (UUID) | optional | IsString | Denormalized creator id (for fast creator analytics joins). No FK validation โ application-level link to the main DB. |
referrer | string | optional | IsString | Document.referrer. Server extracts referrerDomain from this (new URL(referrer).hostname.replace('www.', '')); falls back to 'direct'. |
utmSource / utmMedium / utmCampaign / utmTerm / utmContent | string | optional | IsString | Standard UTM params from the URL query string. Stored verbatim. |
device / browser / os / osVersion | string | optional | IsString (osVersion MaxLength(20)) | Overridden by server-side UA parse when possible โ see callout. |
screenWidth / screenHeight | integer | optional | Min(0) Max(7680) | From window.screen.*. |
connectionType | string | optional | MaxLength(20) | From navigator.connection.effectiveType (e.g. '4g', 'wifi'). |
timezone | string | optional | MaxLength(50) | Client timezone. Server overrides with GeoIP timezone if available. |
language | string | optional | IsString | navigator.language. |
trackingToken | string | optional* | IsString | Required when bioPageId is present, otherwise ignored. See HMAC callout. |
Headers
| Header | Required | Notes |
|---|---|---|
User-Agent | (always sent) | Server runs the bot regex AND UAParser on this header. |
cf-connecting-ip / x-forwarded-for / cf-ipcountry / cf-ipcity | optional | Used as the IP source for GeoIP and as fallback country/city when the GeoIP DB has no row. |
Content-Type: application/json | โ | Standard. |
No Authorization โ endpoint is @Public().
Response
200 OK โ ApiResponseOf<SessionCreatedDto>
{
"success": true,
"data": {
"id": "ses-uuid-...",
"createdAt": "2026-04-30T10:00:00.000Z"
}
}When tracking is rejected:
{ "success": true, "data": { "id": null } }| Field | Type | Notes |
|---|---|---|
id | string (UUID) | null | Session id. null is a soft no-op โ see warn callout for the four rejection paths. |
createdAt | string (ISO 8601) | Only present when id is non-null. The server timestamp the row got. |
Hold on to id and pass it to subsequent pageview / heartbeat / identify calls.
Errors
| HTTP | Code | Reason |
|---|---|---|
400 | (DTO validation) | Missing visitorId or landingPage; screenWidth/Height out of range; etc. |
429 | THROTTLE_LIMIT_EXCEEDED | Over 10 req/min from this IP. |
This endpoint never returns 403/404/500 on tracking rejection โ those become 200 { id: null } to keep the client UX simple. Real failures (validation, throttle) still bubble up.
Side effects
ThrottleGuardincrementsthrottle:<ip>:POST:/api/v1/analytics/session(10-req / 60s).- Bot filter: regex against
User-Agent(/bot|crawl|spider|slurp|baidu|yandex|bing|google|facebook|twitter|linkedin|whatsapp|telegram|preview|fetch|curl|wget|python|java|php|ruby|go-http/i). Match โ return{ id: null }. - GDPR gate:
consentAnalytics !== 'granted'โ return{ id: null }. - UA parse:
UAParser(userAgent)โ derivedevice(mobile/tablet/desktop),browser,os,osVersion. These override the client's values (with a fall-through to the client value when UA parse can't determine it). - GeoIP (scoped inside
try/catchso the raw IP never leaks to error logs):- Resolve client IP:
cf-connecting-ip>x-forwarded-for[0].trim()>req.ip. geoipService.lookup(ip)โcountry, city, region, continent, timezone.- Fall-through:
countryandcityusecf-ipcountry/cf-ipcityheaders if GeoIP has no row. - On exception โ silent catch, all geo fields stay
null. The raw IP is never persisted, never logged.
- Resolve client IP:
- HMAC tracking token check (only when
bioPageIdis set): rebuild HMAC against current and previous 5-min bucket; mismatch โ log debug'rejected as spam', returnnull. - Hash visitorId:
SHA256(visitorId).hexโ replaces the original. - Extract
referrerDomainfromreferrer:new URL(referrer).hostname.replace('www.', ''); URL-parse failure โ'direct'; missing referrer โ'direct'. - Write row to Analytics DB:
analyticsDb.analyticsSession.create({ data: {...} }). On exception โ log error, return{ id: null }. - Return
{ id, createdAt }(or{ id: null }).
Code samples
curl -X POST https://api.bio.re/api/v1/analytics/session \
-H 'Content-Type: application/json' \
-d '{
"visitorId": "anon-localStorage-uuid",
"landingPage": "https://bio.re/alice",
"consentAnalytics": "granted",
"referrer": "https://www.instagram.com/alice",
"utmSource": "instagram",
"utmMedium": "story",
"utmCampaign": "spring-launch",
"screenWidth": 390,
"screenHeight": 844,
"language": "en-US",
"bioPageId": "11111111-2222-3333-4444-555555555555",
"trackingToken": "<32-hex-from-SSR-html>"
}'type CreateSessionInput = {
visitorId: string;
landingPage: string;
consentAnalytics: 'granted' | 'denied' | string; // must be 'granted'
bioPageId?: string;
creatorId?: string;
referrer?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmTerm?: string;
utmContent?: string;
trackingToken?: string;
device?: string;
browser?: string;
os?: string;
osVersion?: string;
screenWidth?: number;
screenHeight?: number;
connectionType?: string;
timezone?: string;
language?: string;
};
type CreateSessionResult = { id: string | null; createdAt?: string };
async function createSession(input: CreateSessionInput): Promise<CreateSessionResult> {
const res = await fetch('https://api.bio.re/api/v1/analytics/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
return json.data; // { id: string | null, createdAt? }
}// 1. Read consent state from your cookie banner / preference store.
const consent = readAnalyticsConsent(); // 'granted' | 'denied'
// 2. Stable anonymous id from localStorage.
const visitorId = (() => {
let v = localStorage.getItem('bre.vid');
if (!v) {
v = crypto.randomUUID();
localStorage.setItem('bre.vid', v);
}
return v;
})();
// 3. Open the session โ `id: null` is a soft no-op, don't surface as an error.
const { id: sessionId } = await createSession({
visitorId,
landingPage: location.href,
consentAnalytics: consent,
referrer: document.referrer || undefined,
utmSource: new URL(location.href).searchParams.get('utm_source') || undefined,
bioPageId: window.__BIO_PAGE_ID__, // injected by SSR
trackingToken: window.__TRACKING_TOKEN__, // injected by SSR
screenWidth: screen.width,
screenHeight: screen.height,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
// 4. If we got an id, instrument the rest of the page lifecycle.
if (sessionId) {
recordPageView({ sessionId, url: location.href, path: location.pathname });
setInterval(() => sessionHeartbeat(sessionId), 30_000);
}Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
curl -X POST "https://loading/api/v1/analytics/session" \ -H "Content-Type: application/json" \ -d '{ "visitorId": "string", "landingPage": "string" }'{
"success": true,
"data": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/analytics/analytics.controller.ts | 82โ144 (createSession โ bot, GDPR, UA, GeoIP, IP-source priority) |
| Bot regex | apps/api-core/src/modules/analytics/analytics.controller.ts | 190โ193 (isBot โ `bot |
| DTO (request) | apps/api-core/src/modules/analytics/dto/tracking.dto.ts | 5โ27 (CreateSessionDto) |
| DTO (response) | apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts | 12โ20 (SessionCreatedDto) |
| Service | apps/api-core/src/modules/analytics/traffic-tracking.service.ts | 52โ137 (createSession โ token check, hash, referrerDomain, DB write), 6โ8 (hashVisitorId โ SHA256), 21โ50 (HMAC token gen + verify) |
| GeoIP package | @biore/geoip | GeoIPService.lookup() (returns { country, city, region, continent, timezone }) |
| Prisma model | packages/prisma-analytics/prisma/schema.prisma | AnalyticsSession lines 46โ96 (Analytics DB โ separate from main DB) |
Track Event
Authenticated event tracking โ emits a FunnelEvent row tied to the current user. Killable via admin config. Validates JSON property payload size. Writes to the main Postgres database, not the Analytics DB.
Record Page View
Public pageview endpoint. Atomic session-counter update, bounce-flag flip on the second view, automatic duration backfill on the previous view. Silently swallows errors.