# BIO.RE Developer Portal (/docs)
The BIO.RE Developer Portal documents every client-facing endpoint of the [BIO.RE](https://bio.re) creator monetization platform. **19 modules, 175 endpoint pages** โ every claim cited back to the backend code (`path:line`), every response shape derived from a real DTO, every error code traced to the exception that throws it.
Use the sidebar to browse, the search bar to find a specific endpoint, or the module cards below to jump straight to a section.
## Browse by module [#browse-by-module]
## Quick start by use case [#quick-start-by-use-case]
| If you are building... | Start here |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sign-up + login flow** | [Register](/docs/auth/register) โ [Verify Email](/docs/auth/verify-email) โ [Login](/docs/auth/login) |
| **Forgotten password flow** | [Forgot Password](/docs/auth/forgot-password) โ [Reset Password](/docs/auth/reset-password) |
| **Two-factor authentication** | [Setup 2FA](/docs/auth/2fa-setup) โ [Verify](/docs/auth/2fa-verify) โ [Login with 2FA](/docs/auth/login-2fa) |
| **OAuth (Google, etc.)** | [List Providers](/docs/auth/oauth-providers) โ [OAuth Login](/docs/auth/oauth-login) โ [Link to Account](/docs/auth/oauth-link) |
| **Creator onboarding** | [Upgrade to Creator](/docs/creator/upgrade) โ [Update Bio](/docs/creator/bio-page-update) โ [Add Bio Links](/docs/creator/bio-link-add) โ [Stripe Connect](/docs/creator/stripe-connect-initiate) โ [KYC](/docs/creator/kyc-start) |
| **Send + receive messages** | [Send Message](/docs/message/send) โ [List Conversations](/docs/message/conversations) โ [Reply](/docs/message/reply) |
| **Wallet top-up** | [Wallet Packages](/docs/payment/wallet-packages) โ [Load Wallet](/docs/payment/wallet-load) โ [Activity](/docs/payment/wallet-activity) |
| **Bio page traffic instrumentation** | [Create Session](/docs/analytics/session-create) โ [Record Pageview](/docs/analytics/pageview) โ [Heartbeat](/docs/analytics/session-heartbeat) โ [Page Leave](/docs/analytics/pageview-leave) |
| **Creator analytics dashboard** | [Overview](/docs/creator-analytics/overview) โ [Traffic](/docs/creator-analytics/traffic) โ [Trend](/docs/creator-analytics/trend) โ [Links](/docs/creator-analytics/links) โ [CSV Export](/docs/creator-analytics/export) |
| **Notification preferences UI** | [List Notifications](/docs/notification/list) โ [Preferences](/docs/notification/preferences) โ [Update Preferences](/docs/notification/preferences-update) โ [Web Push Subscribe](/docs/notification/webpush-subscribe) |
| **GDPR data export / delete** | [Request Export](/docs/user/gdpr-export) โ [Check Status](/docs/user/gdpr-export-status) โ [Download](/docs/user/gdpr-export-download) โ [Request Delete](/docs/user/gdpr-delete) |
| **Localization** | [List Locales](/docs/i18n/locales) โ [Bundle](/docs/i18n/bundle) โ [Namespace Bundle](/docs/i18n/namespace) |
| **Support ticketing** | [List My Tickets](/docs/support/list) โ [Create](/docs/support/create) โ [Reply](/docs/support/reply) |
## How to read an endpoint page [#how-to-read-an-endpoint-page]
Every endpoint page follows the same shape:
1. **Header** โ method + path + scope badge (๐ Public / ๐ Bearer / ๐ค Admin) + rate limit.
2. **Callouts** โ non-obvious behavior, gotchas, kill switches, vendor abstraction notes.
3. **Request** โ path / query / body / header tables, with `class-validator` rules preserved.
4. **Response** โ success envelope + every error code with `i18nKey`.
5. **Side effects** โ DB writes, queued jobs, audit log entries, kill-switch gates, cache invalidations.
6. **Code samples** โ `curl` + `TypeScript fetch` + `TanStack Query` hook (copy-paste ready).
7. **Try it** โ interactive playground with auto-generated cURL/JS/Go/Python/Java/C# samples.
8. **Source** โ `path:line` references back to the backend code (5-source verify: controller + DTO + service + Prisma + cache/util).
## Conventions across the API [#conventions-across-the-api]
* **Response envelope** โ every successful response is `{ "success": true, "data": T }`. Errors are `{ "success": false, "error": { code, message, i18nKey?, details?, correlationId } }`.
* **Refresh tokens** โ set as `httpOnly`, `secure`, `SameSite=Strict` cookie scoped to `.bio.re`. Browsers send + receive automatically; mobile clients can pass via body.
* **Idempotency** โ mutating endpoints accept `Idempotency-Key` header where supported; the side-effects table notes which writes are atomic.
* **Locales** โ 20 active locales, error responses include `i18nKey` for UI mapping.
* **Admin-managed providers** โ every external integration (email, SMS, push, captcha, KYC, payment, OAuth, storage) is selected by admin at runtime. The portal **never names the active vendor** (e.g., "SendGrid" / "Twilio"); endpoints reference the abstraction (`external.email.active_provider`, etc.). Frontend reads the abstract token; the backend resolves to whichever vendor is currently active. Stripe is the lone exception โ load-bearing infra, not a swappable vendor.
* **Two databases** โ most endpoints write to the main Postgres (`@biore/prisma`). Analytics tracking endpoints write to a separate Analytics DB (`@biore/prisma-analytics`) โ joins between the two happen in application code.
## Machine-readable surfaces [#machine-readable-surfaces]
| Surface | URL | Purpose |
| ----------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------- |
| **OpenAPI spec** | [`/openapi/full.json`](/openapi/full.json) | Full Swagger spec โ feeds the "Try it" playground on every endpoint page (945 KB). |
| **Public-only OpenAPI** | [`/openapi/public.json`](/openapi/public.json) | Public endpoints only. |
| **User-auth OpenAPI** | [`/openapi/user.json`](/openapi/user.json) | Public + JWT-bearer endpoints. |
| **Admin OpenAPI** | [`/openapi/admin.json`](/openapi/admin.json) | Admin-scope endpoints. |
| **Sitemap** | [`/sitemap.xml`](/sitemap.xml) | Every URL on this site (179 entries). |
| **`llms.txt`** | [`/llms.txt`](/llms.txt) | Compact module + endpoint index for LLMs. |
| **`llms-full.txt`** | [`/llms-full.txt`](/llms-full.txt) | Full content of every endpoint page in one file (1.4 MB) for LLM consumption. |
| **Per-page Markdown** | `/llms.mdx/docs//content.md` | Raw MDX of any individual page. |
# Record Page Leave (/docs/analytics/pageview-leave)
`PATCH /api/v1/analytics/pageview/:id/leave` โ ๐ **Public** ยท Rate limit: **10 req / minute**
Finalizes the duration and scroll depth of a single pageview. Earlier pageviews in the same session get their `duration` back-filled automatically by the next [`POST /pageview`](/docs/analytics/pageview), so this endpoint really only matters for the **last** pageview of a session โ the one followed by a tab close, navigation away, or full app exit.
**Use `navigator.sendBeacon` from `pagehide` / `beforeunload`.** A regular `fetch` from those handlers usually gets cancelled by the browser. `sendBeacon` is queued by the browser and delivered after the page unloads. The body is JSON; the server will accept it.
**`duration` is capped at 3600s server-side.** `Math.min(duration, 3600)` โ anything you pass over an hour gets clamped. **`scrollDepth` is clamped to `[0, 100]`** before write โ pass percentages, not pixel values.
**Silent failures.** `update.catch(() => {})` โ if the pageview id is wrong or the row's been pruned, you still get `200 { ok: true }` and no error.
## Request [#request]
### Path parameters [#path-parameters]
| Param | Type | Required | Notes |
| ----- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | string (UUID) | โ | The pageview row id from [`POST /analytics/pageview`](/docs/analytics/pageview). Not validated server-side โ bad ids no-op silently. |
### Body โ `PageLeaveDto` [#body--pageleavedto]
| Field | Type | Required | Validation | Notes |
| ------------- | ---------------- | -------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `duration` | number (seconds) | โ | `IsNumber Min(0) Max(3600)` | Whole or fractional seconds the page was visible. DTO validates `[0, 3600]`; service additionally `Math.min(duration, 3600)` as belt-and-braces. |
| `scrollDepth` | number (percent) | optional | `IsNumber Min(0) Max(100)` | Furthest scroll position as a 0-100 percentage. DTO validates the range; service additionally `Math.min(Math.max(value, 0), 100)`. |
### Headers [#headers]
| Header | Required | Notes |
| -------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type: application/json` | โ | `sendBeacon` will send `application/x-www-form-urlencoded` or `Blob` mime-type by default โ wrap your JSON in a typed `Blob` to keep it `application/json`. See the sample. |
No auth.
## Response [#response]
### `200 OK` โ `ApiResponseOf` [#200-ok--apiresponseofpageleaveresultdto]
```json
{ "success": true, "data": { "ok": true } }
```
Same caveats as heartbeat โ `ok: true` is "request reached service," not "row was updated."
### Errors [#errors]
| HTTP | Reason |
| ----- | ------------------------------------------------------------------------------------------------------ |
| `400` | DTO validation (`duration` outside `[0, 3600]`, `scrollDepth` outside `[0, 100]`, missing `duration`). |
| `429` | Over 10 req/min from this IP. |
## Side effects [#side-effects]
1. `ThrottleGuard` (10 req / 60s).
2. `analyticsDb.analyticsPageView.update({ where: { id }, data: { duration: Math.min(data.duration, 3600), scrollDepth: clamped(0..100) ?? null } }).catch(() => {})`. Single SQL UPDATE; mis-id silently swallowed.
3. Return `{ ok: true }`.
## Code samples [#code-samples]
```bash
curl -X PATCH https://api.bio.re/api/v1/analytics/pageview/pv-uuid/leave \
-H 'Content-Type: application/json' \
-d '{ "duration": 47, "scrollDepth": 65 }'
```
```ts
async function pageLeave(
pageViewId: string,
data: { duration: number; scrollDepth?: number },
): Promise {
await fetch(`https://api.bio.re/api/v1/analytics/pageview/${pageViewId}/leave`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).catch(() => {});
}
```
```ts
// Fires reliably during page unload โ fetch usually gets cancelled in unload handlers.
function leaveBeacon(
pageViewId: string,
data: { duration: number; scrollDepth?: number },
): boolean {
const url = `/api/v1/analytics/pageview/${pageViewId}/leave`;
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
return navigator.sendBeacon(url, blob);
}
// Wire it up โ pagehide is more reliable than beforeunload on mobile Safari.
window.addEventListener('pagehide', () => {
if (!lastPageViewId) return;
const duration = Math.floor((Date.now() - mountedAt) / 1000);
const scrollDepth = Math.round(
(window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100,
);
leaveBeacon(lastPageViewId, { duration, scrollDepth });
});
```
## Try it [#try-it]
## Source [#source]
| Source | Path | Lines |
| -------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `168โ177` (`pageLeave`) |
| DTO (request) | `apps/api-core/src/modules/analytics/dto/tracking.dto.ts` | `38โ41` (`PageLeaveDto`) |
| DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `43โ46` (`PageLeaveResultDto`) |
| Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `205โ213` (`pageLeave` โ duration cap + scrollDepth clamp + silent fail) |
| Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsPageView.duration / scrollDepth` lines `112โ113` |
# Record Page View (/docs/analytics/pageview)
`POST /api/v1/analytics/pageview` โ ๐ **Public** ยท Rate limit: **30 req / minute**
Records one pageview against an existing analytics session. Each successful call does three things in the database โ creates the pageview row, atomically increments the parent session's counter (and flips the `bounced` flag from `true` โ `false` on the second view), and back-fills `duration` on the previous pageview. All three are best-effort: failures are swallowed and the response is `200 { id: null }` so client instrumentation never errors out a page render.
**Atomic counter update prevents the bounce-flag race.** The session's `pageViews` increment and the `bounced` flag flip happen in a single SQL `UPDATE ... SET pageViews = pageViews + 1, bounced = CASE WHEN pageViews + 1 >= 2 THEN false ELSE true END`. Earlier read-then-write versions had a race where two concurrent pageviews could both observe `pageViews = 1` and incorrectly set `bounced = true`.
**Previous pageview's `duration` is back-filled here, not on `pageLeave`.** When this endpoint runs, it looks up the user's most-recent prior pageview in the same session and sets its `duration = (this.enteredAt - prev.enteredAt) / 1000`, **capped 0โ3600s** (anything outside that range is silently dropped). The dedicated [`pageLeave`](/docs/analytics/pageview-leave) endpoint is only needed to capture duration on the *last* view in a session (no follow-up pageview will ever back-fill it).
**Silent failures.** If the session doesn't exist (expired / cleaned up / wrong id), the page-view insert may fail or the session UPDATE will simply match zero rows. Either way the endpoint returns `200 { id: null }` (or `{ id }` from the new row even when the session update no-ops). **Don't treat a non-null `id` as proof the session counter advanced** โ the row itself can land while the session UPDATE matches nothing.
## Request [#request]
### Body โ `RecordPageViewDto` [#body--recordpageviewdto]
| Field | Type | Required | Validation | Notes |
| ------------- | ------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sessionId` | string (UUID) | โ | `IsString` | The session id from [`POST /analytics/session`](/docs/analytics/session-create). Server doesn't validate it exists โ non-matching ids land an orphan pageview row. |
| `url` | string | โ | `IsString` | Full URL of the viewed page. Stored verbatim and used as the session's `exitPage`. |
| `path` | string | โ | `IsString` | Normalized path (e.g. `/creators/alice`, `/messages`). |
| `title` | string | optional | `IsString` | `document.title`. |
| `bioPageId` | string (UUID) | optional | `IsString` | The bio page being viewed (if applicable). |
| `linkClicked` | string (UUID) | optional | `IsString` | If this view was triggered by a bio link click, the id of that link. Use to reconstruct the click flow. |
### Headers [#headers]
| Header | Required | Notes |
| -------------------------------- | -------- | --------- |
| `Content-Type: application/json` | โ | Standard. |
No auth โ endpoint is `@Public()`.
## Response [#response]
### `200 OK` โ `ApiResponseOf` [#200-ok--apiresponseofpageviewcreateddto]
```json
{
"success": true,
"data": {
"id": "pv-uuid-...",
"enteredAt": "2026-04-30T10:00:01.123Z"
}
}
```
| Field | Type | Notes |
| ----------- | --------------------- | --------------------------------------------------------------------------- |
| `id` | string (UUID) \| null | New pageview row id, or `null` when the row insert threw (caught + logged). |
| `enteredAt` | string (ISO 8601) | When the row was created (DB `now()`). Only present when `id` is non-null. |
### Errors [#errors]
| HTTP | Reason |
| ----- | ------------------------------------------------------ |
| `400` | DTO validation (missing `sessionId` / `url` / `path`). |
| `429` | Over 30 req/min from this IP. |
DB / session-not-found failures are **not** errors โ they become `200 { id: null }`.
## Side effects [#side-effects]
1. `ThrottleGuard` (30 req / 60s).
2. `analyticsDb.analyticsPageView.create({ data: { id: randomUUID(), sessionId, url, path, title, bioPageId, linkClicked } })`.
3. **Atomic session update** via `analyticsDb.$executeRaw`:
```sql
UPDATE "AnalyticsSession"
SET "pageViews" = "pageViews" + 1,
"exitPage" = $url,
"lastActivityAt" = NOW(),
"bounced" = CASE WHEN "pageViews" + 1 >= 2 THEN false ELSE true END
WHERE id = $sessionId::uuid
```
`.catch(() => {})` โ failures swallowed.
4. **Back-fill prev `duration`**: `findFirst({ sessionId, id: NOT this.id, orderBy: enteredAt desc })` โ if found, `update prev.duration = (this.enteredAt - prev.enteredAt) / 1000` when `0 < duration < 3600`. Out-of-range or query failure โ silently skipped.
5. Return `{ id, enteredAt }` (or `{ id: null }` on row-create exception).
## Code samples [#code-samples]
```bash
curl -X POST https://api.bio.re/api/v1/analytics/pageview \
-H 'Content-Type: application/json' \
-d '{
"sessionId": "ses-uuid-from-create",
"url": "https://bio.re/alice",
"path": "/alice",
"title": "Alice on bio.re",
"bioPageId": "11111111-2222-3333-4444-555555555555"
}'
```
```ts
type RecordPageViewInput = {
sessionId: string;
url: string;
path: string;
title?: string;
bioPageId?: string;
linkClicked?: string;
};
type PageViewResult = { id: string | null; enteredAt?: string };
async function recordPageView(input: RecordPageViewInput): Promise {
const res = await fetch('https://api.bio.re/api/v1/analytics/pageview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
return json.data;
}
```
```ts
// React Router / Next.js example โ fire one pageview per route change.
let lastPageViewId: string | null = null;
useEffect(() => {
if (!sessionId) return;
// Fire-and-forget; capture id so pageLeave can finalize duration when leaving.
recordPageView({
sessionId,
url: location.href,
path: location.pathname,
title: document.title,
}).then(({ id }) => { lastPageViewId = id; });
}, [pathname]);
// On unmount / before route change, send the leave event for accurate last-view duration:
useEffect(() => {
return () => {
if (lastPageViewId) {
const duration = Math.floor((Date.now() - mountedAt.current) / 1000);
pageLeave(lastPageViewId, { duration, scrollDepth: getScrollPercent() });
}
};
}, []);
```
## Try it [#try-it]
## Source [#source]
| Source | Path | Lines |
| -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `146โ155` (`recordPageView`) |
| DTO (request) | `apps/api-core/src/modules/analytics/dto/tracking.dto.ts` | `29โ36` (`RecordPageViewDto`) |
| DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `24โ32` (`PageViewCreatedDto`) |
| Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `139โ193` (`recordPageView` โ atomic UPDATE, prev-duration back-fill) |
| Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsPageView` lines `102โ119`, `AnalyticsSession.pageViews / bounced / exitPage / lastActivityAt` `46โ96` |
# Create Analytics Session (/docs/analytics/session-create)
`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, ':<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 [#request]
### Body โ `CreateSessionDto` [#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 [#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 [#response]
### `200 OK` โ `ApiResponseOf` [#200-ok--apiresponseofsessioncreateddto]
```json
{
"success": true,
"data": {
"id": "ses-uuid-...",
"createdAt": "2026-04-30T10:00:00.000Z"
}
}
```
When tracking is rejected:
```json
{ "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 [#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 [#side-effects]
1. `ThrottleGuard` increments `throttle::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 [#code-samples]
```bash
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>"
}'
```
```ts
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 {
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? }
}
```
```ts
// 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 [#try-it]
## Source [#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 | crawl | spider | ...\` UA tokens) |
| 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) | | | |
# Session Heartbeat (/docs/analytics/session-heartbeat)
`PATCH /api/v1/analytics/session/:id/heartbeat` โ ๐ **Public** ยท Rate limit: **5 req / 30 s**
Marks the session as still active and adds 30 seconds to its rolling `duration` counter. Call once every \~30 seconds while the user has the page in the foreground; stop when they background the tab or close the page. **The endpoint hard-codes a `+30` increment** regardless of how long it's actually been since the last beat, so don't call it more often than once per 30s โ you'll over-count time on session.
**`duration` is incremented by a hard-coded 30s per call.** The server does NOT measure the elapsed wall-clock time between heartbeats. Two beats in 1 second still add 60s to `duration`. The throttle (`@Throttle(5, 30)` โ 5 req per 30s) is what protects against accidental spam, but you should still pace your client to one beat per \~30s.
**Failures are silent.** Service does `update.catch(() => {})` โ if the session id doesn't exist or DB is unhappy, you still get `200 { ok: true }`. No way for the client to detect that the session is gone via this endpoint; only the session-create response's `null` id signals real rejection.
**Pair with the Page Visibility API.** The right cadence is "every 30 seconds while `document.visibilityState === 'visible'`." Sending heartbeats from a backgrounded tab inflates active-time metrics. See the code sample.
## Request [#request]
### Path parameters [#path-parameters]
| Param | Type | Required | Notes |
| ----- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | โ | Session id from [`POST /analytics/session`](/docs/analytics/session-create). **Not** validated by `ParseUUIDPipe` โ any string is accepted; mismatches no-op. |
### Headers [#headers]
| Header | Required | Notes |
| -------------------------------- | -------- | -------------- |
| `Content-Type: application/json` | optional | Body is empty. |
No body, no auth.
## Response [#response]
### `200 OK` โ `ApiResponseOf` [#200-ok--apiresponseofheartbeatresultdto]
```json
{ "success": true, "data": { "ok": true } }
```
`ok: true` means "request reached the service" โ not "DB write succeeded" (see warn callout).
### Errors [#errors]
| HTTP | Reason |
| ----- | ------------------------------------------------------------------------------------------------------ |
| `429` | Over 5 req / 30s from this IP. (Throttle is per-IP, not per-session โ multiple tabs share the budget.) |
## Side effects [#side-effects]
1. `ThrottleGuard` (5 req / 30s).
2. `analyticsDb.analyticsSession.update({ where: { id }, data: { lastActivityAt: new Date(), duration: { increment: 30 } } }).catch(() => {})`. **Single SQL UPDATE**, no read-then-write.
3. Return `{ ok: true }` regardless of whether any row was matched.
## Code samples [#code-samples]
```bash
curl -X PATCH https://api.bio.re/api/v1/analytics/session/ses-uuid/heartbeat
```
```ts
async function sessionHeartbeat(sessionId: string): Promise {
await fetch(`https://api.bio.re/api/v1/analytics/session/${sessionId}/heartbeat`, {
method: 'PATCH',
}).catch(() => {}); // never throw from instrumentation
}
```
```ts
// Fire a heartbeat every 30s, but only while the tab is visible.
// Pauses on visibilitychange = 'hidden', resumes on 'visible'.
function startHeartbeat(sessionId: string): () => void {
let timer: number | null = null;
const start = () => {
if (timer != null) return;
timer = window.setInterval(() => sessionHeartbeat(sessionId), 30_000);
};
const stop = () => {
if (timer != null) { clearInterval(timer); timer = null; }
};
const onVisibility = () => {
if (document.visibilityState === 'visible') start(); else stop();
};
document.addEventListener('visibilitychange', onVisibility);
if (document.visibilityState === 'visible') start();
// Return cleanup
return () => {
document.removeEventListener('visibilitychange', onVisibility);
stop();
};
}
```
## Try it [#try-it]
## Source [#source]
| Source | Path | Lines |
| -------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `157โ166` (`sessionHeartbeat`) |
| DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `36โ39` (`HeartbeatResultDto`) |
| Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `195โ203` (`heartbeat` โ fixed `+30` increment, silent failure) |
| Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsSession.duration / lastActivityAt` lines `53โ54` |
# Identify Session After Login (/docs/analytics/session-identify)
`PATCH /api/v1/analytics/session/:id/identify` โ ๐ **Bearer**
Sets `AnalyticsSession.userId` for an existing session. Call this **once, right after login or signup completes** โ the session was created anonymously (likely before the user authenticated), and this pulls the user identity onto it so all the prior pageviews in that session can be attributed correctly.
**Idempotent and overwrite-friendly.** The service does a flat `update({ where: { id }, data: { userId } })` โ calling it twice with the same userId is a no-op. Calling it with a different userId overwrites the previous one, so don't share session ids across users (a logout-then-login on the same anonymous session would reattribute prior views to the second user).
**No 404 / 401 cross-checks.** The service silently swallows `update` failures (`update.catch(() => {})`). If the session id doesn't exist or the DB rejects the write, the response is still `200 { ok: true }` โ you can't programmatically detect a bad id. The auth check (`@CurrentUser('id')` requires a valid bearer) is the only real gate.
**`userId` comes from the JWT, not the body.** The endpoint reads `userId` via `@CurrentUser('id')` โ there is no way to identify a session as someone else. To unbind, you'd need a separate endpoint (none today). To re-identify after logout, the new session should be created fresh.
## Request [#request]
### Path parameters [#path-parameters]
| Param | Type | Required | Notes |
| ----- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `id` | string (UUID) | โ | Session id from [`POST /analytics/session`](/docs/analytics/session-create). Not validated server-side โ bad ids no-op. |
### Headers [#headers]
| Header | Required | Notes |
| ------------------------------------- | -------- | ------------------------------------------------------------------------ |
| `Authorization: Bearer ` | โ | Global `JwtAuthGuard`. The user from this token is bound to the session. |
No body.
## Response [#response]
### `200 OK` โ `ApiResponseOf` [#200-ok--apiresponseofidentifysessionresultdto]
```json
{ "success": true, "data": { "ok": true } }
```
### Errors [#errors]
| HTTP | Reason |
| ----- | ------------------------------- |
| `401` | Missing / invalid bearer token. |
The endpoint has no documented `404` or `400` paths โ bad session ids and DB failures collapse to a successful response.
## Side effects [#side-effects]
1. Decode bearer (global `JwtAuthGuard`); `userId = req.user.id`.
2. `analyticsDb.analyticsSession.update({ where: { id }, data: { userId } }).catch(() => {})`. Single SQL UPDATE; failures swallowed.
3. Return `{ ok: true }`.
**No throttle on this endpoint.** The class registers `@UseGuards(ThrottleGuard)` but this handler has no `@Throttle()` decorator and the guard returns `true` when no decorator is present. In practice clients should call it at most once per session (right after auth completes), so a missing throttle is fine.
## Code samples [#code-samples]
```bash
curl -X PATCH https://api.bio.re/api/v1/analytics/session/ses-uuid/identify \
-H "Authorization: Bearer $ACCESS_TOKEN"
```
```ts
async function identifySession(
accessToken: string,
sessionId: string,
): Promise {
await fetch(`https://api.bio.re/api/v1/analytics/session/${sessionId}/identify`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${accessToken}` },
}).catch(() => {});
}
```
```ts
// Run this once, immediately after a successful login/signup.
// The session was started anonymously when the page first loaded.
async function onLoginSuccess(accessToken: string) {
const sessionId = window.__ANALYTICS_SESSION_ID__; // captured from createSession()
if (!sessionId) return;
await identifySession(accessToken, sessionId);
// No need to re-fire; the binding persists for the lifetime of the session.
}
```
## Try it [#try-it]
## Source [#source]
| Source | Path | Lines |
| ----------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `179โ188` (`identifySession`) |
| DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `50โ53` (`IdentifySessionResultDto`) |
| Service | `apps/api-core/src/modules/analytics/traffic-tracking.service.ts` | `215โ220` (`identifyUser` โ single UPDATE, silent fail) |
| Prisma model | `packages/prisma-analytics/prisma/schema.prisma` | `AnalyticsSession.userId` line `49` (application-level FK to main DB User) |
| Throttle behavior | `apps/api-core/src/common/guards/throttle.guard.ts` | `43โ48` (no decorator โ `return true`) |
# Track Event (/docs/analytics/track)
`POST /api/v1/analytics/track` โ ๐ **Bearer** ยท Rate limit: **60 req / minute**
Records a named event (signup step, action, button click, โฆ) attributed to the current user. This endpoint coexists with the public session-tracking endpoints โ but **`track` writes to the main database** (`FunnelEvent` row in `@biore/prisma`), not the separate Analytics DB. Use it for funnel events that must be reliably joined to the user table; use [`POST /analytics/session`](/docs/analytics/session-create) + [`POST /analytics/pageview`](/docs/analytics/pageview) for traffic instrumentation.
**Killable from admin config.** The service reads `analytics.tracking_enabled` (string, default `'true'`). When set to anything other than `'true'`, the endpoint returns `200 { tracked: true }` without writing โ clients see success but no row lands. Useful for kill-switching analytics during incidents without redeploying.
**`properties` payload is size-capped.** Server reads `analytics.max_event_data_bytes` (default `10000`) and runs `validateEventData(properties, max)`. Anything over the cap throws `400 analytics.event.invalid_data`. Keep payloads small; for large analytics blobs use a dedicated endpoint or batch.
**Two databases, one module.** This endpoint writes to `prisma.funnelEvent` (main `@biore/prisma` Postgres). The session/pageview endpoints write to `analyticsDb` (`@biore/prisma-analytics` โ a separate Postgres on the Analytics DB service). Funnel events are joinable to the User table; session events are not. Plan your queries accordingly.
## Request [#request]
### Body โ `TrackEventDto` [#body--trackeventdto]
| Field | Type | Required | Validation | Notes |
| ------------ | ------ | -------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event` | string | โ | `IsString` | Event name (e.g. `signup_step_complete`, `bio_link_click`). No enum โ free-form. |
| `sessionId` | string | optional | `IsString` | Optional join key โ typically the analytics session id from [`POST /session`](/docs/analytics/session-create) so funnel events can be correlated with traffic state. Stored verbatim โ server doesn't validate it exists. |
| `deviceType` | string | optional | `IsString` | Free-form (`mobile` / `desktop` / `tablet`). Stored as-is on `FunnelEvent.eventData`? **No** โ the column is dropped from persistence (only `event`, `userId`, `sessionId`, `eventData` are written). Pass it for API symmetry; it's ignored. |
| `locale` | string | optional | `IsString` | Same as `deviceType` โ accepted by the DTO but the service drops it from the write. |
| `properties` | object | optional | `IsObject` | Arbitrary JSON. Persisted as `FunnelEvent.eventData`. **Size-capped** (see warn callout). |
### Headers [#headers]
| Header | Required | Notes |
| ------------------------------------- | -------- | --------------------------------------------------------------------------------------- |
| `Authorization: Bearer ` | โ | Global `JwtAuthGuard`. `userId` is forwarded from the JWT โ clients cannot impersonate. |
| `Content-Type: application/json` | โ | Standard. |
## Response [#response]
### `200 OK` โ `ApiResponseOf` [#200-ok--apiresponseoftrackingresultdto]
```json
{
"success": true,
"data": { "tracked": true }
}
```
| Field | Type | Notes |
| --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tracked` | boolean | Always `true` when the request reaches the service. **Does not** signal that the row landed โ both the kill switch (`analytics.tracking_enabled !== 'true'`) and a swallowed-DB-write path can produce `tracked: true` with no DB effect. Treat as a "request accepted" ack only. |
The controller hard-codes `@HttpCode(200)` so you don't get the framework default `201`.
### Errors [#errors]
| HTTP | Code / `i18nKey` | Reason |
| ----- | ------------------------------ | --------------------------------------------------------------------------------------------------- |
| `400` | `analytics.event.invalid_data` | `properties` failed `validateEventData` (size cap or invalid shape). Payload may include the limit. |
| `400` | (DTO validation) | `event` missing / non-string. |
| `401` | (guard) | Missing / invalid bearer token. |
| `429` | `THROTTLE_LIMIT_EXCEEDED` | Over 60 req/min from this IP. `retryAfter` (seconds) included in the response. |
## Side effects [#side-effects]
1. Decode bearer (global `JwtAuthGuard`); `userId = req.user.id`.
2. `ThrottleGuard` increments `throttle::POST:/api/v1/analytics/track` in Redis cache (60-req / 60s window).
3. Read kill switch โ `configService.getWithDefault('analytics.tracking_enabled', 'true')`. If not `'true'` โ return `{ tracked: true }` with no further work.
4. Read size cap โ `configService.getNumberWithDefault('analytics.max_event_data_bytes', 10000)`. Run `validateEventData(properties, max)`. Failure โ throw `400`.
5. Build event row: `{ id: randomUUID(), userId, sessionId, eventType: event, eventData: properties ?? {} }`.
6. `prisma.funnelEvent.create({ data })` โ **direct write to the main DB**, no queue. (A historical Redis-queued path was removed because the module never imported `RedisModule`.)
7. Increment `analyticsEventTrackedTotal` Prometheus counter.
8. Log `[analytics] Event tracked: for user ` (debug level).
9. Return `{ tracked: true }` (controller wraps as `{ success: true, data: { tracked: true } }`).
## Code samples [#code-samples]
```bash
curl -X POST https://api.bio.re/api/v1/analytics/track \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"event": "bio_link_click",
"sessionId": "session-uuid-from-create",
"properties": { "linkId": "abc-123", "position": 2 }
}'
```
```ts
type TrackInput = {
event: string;
sessionId?: string;
properties?: Record;
};
async function track(accessToken: string, input: TrackInput): Promise {
const res = await fetch('https://api.bio.re/api/v1/analytics/track', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
if (!res.ok) {
const json = await res.json().catch(() => null);
throw Object.assign(new Error(json?.error?.message ?? 'Track failed'), {
code: json?.error?.code,
});
}
// Don't await success โ fire-and-forget at the call site if you don't care.
}
```
```ts
import { useMutation } from '@tanstack/react-query';
export function useTrackEvent() {
return useMutation({
mutationFn: async (input: TrackInput) => {
const res = await fetch('/api/v1/analytics/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) {
// Don't surface to the user โ instrumentation failures shouldn't be UX errors.
return;
}
},
// Don't retry โ duplicates are worse than missing one event.
retry: false,
});
}
```
## Try it [#try-it]
## Source [#source]
| Source | Path | Lines |
| -------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| Controller | `apps/api-core/src/modules/analytics/analytics.controller.ts` | `52โ59` (class), `68โ78` (`track`) |
| DTO (request) | `apps/api-core/src/modules/analytics/dto/index.ts` | `9โ15` (`TrackEventDto`) |
| DTO (response) | `apps/api-core/src/modules/analytics/dto/analytics-client-response.dto.ts` | `5โ8` (`TrackingResultDto`) |
| Service | `apps/api-core/src/modules/analytics/analytics.service.ts` | `23โ46` (`trackEvent` โ kill switch, size cap, direct DB write) |
| Validation | `apps/api-core/src/common/json-validators.ts` | `validateEventData` (size cap enforcement) |
| Config keys | (admin-managed) | `analytics.tracking_enabled` (string, kill switch), `analytics.max_event_data_bytes` (number, default `10000`) |
| Prisma model | `packages/prisma/prisma/schema.prisma` | `FunnelEvent` lines `1681โ1695` (main DB โ NOT analytics DB) |
# Get Active Announcements (/docs/content/announcements)
`GET /api/v1/public/announcements` โ ๐ **Public** ยท Rate limit: **60 req / minute**
Returns active announcements โ site-wide banners / notices admin-managed. Filter is `active: true AND startsAt <= now (or null) AND endsAt >= now (or null)`. **Cap is 50** (server-side `take: 50`). Each item's `content` is sanitized via the same helper as CMS pages (`` blocks (regex with greedy minimum match).
* `` blocks (same pattern).
* Inline event handlers โ any attribute starting with `on` followed by a word (e.g. `onclick="..."`, `onerror='...'`).
* `