BIO.RE
Analytics

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

FieldTypeRequiredValidationNotes
visitorIdstringโœ“IsStringAnonymous client-side UUID (e.g. from localStorage). Hashed before write.
landingPagestringโœ“IsStringFirst URL the user landed on (full URL).
consentAnalyticsstringโœ“ for trackingIsString MaxLength(50)Must be 'granted' for the row to land. Anything else โ†’ 200 { id: null }.
bioPageIdstring (UUID)optionalIsStringWhen set, also requires a valid trackingToken โ€” otherwise the session is rejected.
creatorIdstring (UUID)optionalIsStringDenormalized creator id (for fast creator analytics joins). No FK validation โ€” application-level link to the main DB.
referrerstringoptionalIsStringDocument.referrer. Server extracts referrerDomain from this (new URL(referrer).hostname.replace('www.', '')); falls back to 'direct'.
utmSource / utmMedium / utmCampaign / utmTerm / utmContentstringoptionalIsStringStandard UTM params from the URL query string. Stored verbatim.
device / browser / os / osVersionstringoptionalIsString (osVersion MaxLength(20))Overridden by server-side UA parse when possible โ€” see callout.
screenWidth / screenHeightintegeroptionalMin(0) Max(7680)From window.screen.*.
connectionTypestringoptionalMaxLength(20)From navigator.connection.effectiveType (e.g. '4g', 'wifi').
timezonestringoptionalMaxLength(50)Client timezone. Server overrides with GeoIP timezone if available.
languagestringoptionalIsStringnavigator.language.
trackingTokenstringoptional*IsStringRequired when bioPageId is present, otherwise ignored. See HMAC callout.

Headers

HeaderRequiredNotes
User-Agent(always sent)Server runs the bot regex AND UAParser on this header.
cf-connecting-ip / x-forwarded-for / cf-ipcountry / cf-ipcityoptionalUsed 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 } }
FieldTypeNotes
idstring (UUID) | nullSession id. null is a soft no-op โ€” see warn callout for the four rejection paths.
createdAtstring (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

HTTPCodeReason
400(DTO validation)Missing visitorId or landingPage; screenWidth/Height out of range; etc.
429THROTTLE_LIMIT_EXCEEDEDOver 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

  1. ThrottleGuard increments throttle:<ip>:POST:/api/v1/analytics/session (10-req / 60s).
  2. 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 }.
  3. GDPR gate: consentAnalytics !== 'granted' โ†’ return { id: null }.
  4. UA parse: UAParser(userAgent) โ†’ derive device (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).
  5. GeoIP (scoped inside try/catch so 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: country and city use cf-ipcountry / cf-ipcity headers if GeoIP has no row.
    • On exception โ†’ silent catch, all geo fields stay null. The raw IP is never persisted, never logged.
  6. HMAC tracking token check (only when bioPageId is set): rebuild HMAC against current and previous 5-min bucket; mismatch โ†’ log debug 'rejected as spam', return null.
  7. Hash visitorId: SHA256(visitorId).hex โ€” replaces the original.
  8. Extract referrerDomain from referrer: new URL(referrer).hostname.replace('www.', ''); URL-parse failure โ†’ 'direct'; missing referrer โ†’ 'direct'.
  9. Write row to Analytics DB: analyticsDb.analyticsSession.create({ data: {...} }). On exception โ†’ log error, return { id: null }.
  10. 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

POST
/api/v1/analytics/session

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

SourcePathLines
Controllerapps/api-core/src/modules/analytics/analytics.controller.ts82โ€“144 (createSession โ€” bot, GDPR, UA, GeoIP, IP-source priority)
Bot regexapps/api-core/src/modules/analytics/analytics.controller.ts190โ€“193 (isBot โ€” `bot
DTO (request)apps/api-core/src/modules/analytics/dto/tracking.dto.ts5โ€“27 (CreateSessionDto)
DTO (response)apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts12โ€“20 (SessionCreatedDto)
Serviceapps/api-core/src/modules/analytics/traffic-tracking.service.ts52โ€“137 (createSession โ€” token check, hash, referrerDomain, DB write), 6โ€“8 (hashVisitorId โ€” SHA256), 21โ€“50 (HMAC token gen + verify)
GeoIP package@biore/geoipGeoIPService.lookup() (returns { country, city, region, continent, timezone })
Prisma modelpackages/prisma-analytics/prisma/schema.prismaAnalyticsSession lines 46โ€“96 (Analytics DB โ€” separate from main DB)

On this page