Get Bio Analytics
Owner-only analytics aggregate for a bio page over a configurable time window. Returns total/unique views, top referrers/devices/countries, and per-day view counts.
GET /api/v1/bio/:bioPageId/analytics — 🔑 Bearer
Returns aggregated analytics for a BioPage over a configurable window. Ownership-checked — bioPage.creator.userId must match the bearer's subject; otherwise 403. Aggregates run from a 10,000-row sample of recent BioPageView records — sufficient for typical dashboards, with a documented ceiling for very high-volume creators.
Sampling cap. The aggregator pulls at most 10,000 most-recent views within the window for breakdowns (referrer / device / country). totalViews is the true count (separate query); only the breakdown buckets are derived from the sample. For creators with >10k views per window, the breakdown distributions are accurate but absolute counts in each bucket are sample-scoped.
Top-list caps: topReferrers and countryBreakdown are sliced to the top 20 entries (sorted by count DESC). deviceBreakdown is not sliced — the long tail of devices is small.
Request
Path parameters
| Param | Type | Validation | Notes |
|---|---|---|---|
bioPageId | string (UUID) | ParseUUIDPipe | The bio page to aggregate |
Query parameters
| Param | Type | Default | Validation | Notes |
|---|---|---|---|---|
days | number | 30 | parseInt, server-clamped to [1, 365] | Window length looking back from now |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK — ApiResponseOf<CreatorAnalyticsDto>
{
"success": true,
"data": {
"totalViews": 1500,
"uniqueVisitors": 800,
"topReferrers": [
{ "referrer": "direct", "count": 612 },
{ "referrer": "https://twitter.com", "count": 304 }
],
"deviceBreakdown": [
{ "device": "mobile", "count": 980 },
{ "device": "desktop", "count": 420 },
{ "device": "tablet", "count": 100 }
],
"countryBreakdown": [
{ "country": "US", "count": 540 },
{ "country": "TR", "count": 380 }
],
"viewsByDay": [
{ "date": "2026-04-15", "views": 42 },
{ "date": "2026-04-16", "views": 51 }
]
}
}Top-level fields
| Field | Type | Notes |
|---|---|---|
totalViews | number | True count from count(BioPageView where bioPageId AND createdAt >= since) (not sample-scoped) |
uniqueVisitors | number | Distinct visitorId values across the sample (rows where visitorId is null are excluded) |
topReferrers | array | { referrer, count } — sorted DESC, top 20. Empty / null referrer is bucketed as 'direct'. |
deviceBreakdown | array | { device, count } — not sliced. Empty / null device is bucketed as 'unknown'. |
countryBreakdown | array | { country, count } — sorted DESC, top 20. Empty / null country is bucketed as 'unknown'. |
viewsByDay | array | { date, views } — date is YYYY-MM-DD (server-local PostgreSQL date), sorted ASC. Computed from a separate raw SQL query (COUNT(*) GROUP BY DATE(createdAt)). |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (validation) | bioPageId not UUID |
401 | (guard) | Missing / invalid bearer token |
403 | creator.bio.not_owner | bioPage.creator.userId !== bearer.sub |
404 | creator.bio.not_found | BioPage row missing |
Side effects
- Ownership lookup —
prisma.bioPage.findUnique({ where: { id }, select: { creator: { select: { userId: true } } } }). Missing → 404. Mismatch → 403. - Clamp
days—Math.max(1, Math.min(365, parseInt(days) || 30)). Default 30. - Three queries in parallel:
count(BioPageView)→totalViews(true count, not sampled).findMany(BioPageView, take: 10000, select: visitorId/referrer/device/country)→ sample for breakdown buckets.getViewsByDay()— raw SQLSELECT DATE(createdAt), COUNT(*) GROUP BY DATEordered ASC.
- In-memory aggregation of the sample:
uniqueVisitors = Set(views.filter(v => v.visitorId).map(v => v.visitorId)).size.- Build
referrerMap,deviceMap,countryMap(null/empty bucketed as'direct'/'unknown'). - Sort + slice top 20 for referrers and countries.
- Return assembled object. No mutations.
Code samples
curl 'https://api.bio.re/api/v1/bio/b1a2b3c4-d5e6-7890-abcd-ef1234567890/analytics?days=30' \
-H "Authorization: Bearer $ACCESS_TOKEN"type ReferrerItem = { referrer: string; count: number };
type DeviceItem = { device: string; count: number };
type CountryItem = { country: string; count: number };
type ViewsByDayItem = { date: string; views: number };
type CreatorAnalytics = {
totalViews: number;
uniqueVisitors: number;
topReferrers: ReferrerItem[];
deviceBreakdown: DeviceItem[];
countryBreakdown: CountryItem[];
viewsByDay: ViewsByDayItem[];
};
async function getBioAnalytics(accessToken: string, bioPageId: string, days = 30): Promise<CreatorAnalytics> {
const url = new URL(`https://api.bio.re/api/v1/bio/${bioPageId}/analytics`);
url.searchParams.set('days', String(days));
const res = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Analytics fetch failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useQuery } from '@tanstack/react-query';
export const bioKeys = {
analytics: (bioPageId: string, days: number) =>
['bio', bioPageId, 'analytics', days] as const,
};
export function useBioAnalytics(bioPageId: string, days = 30) {
return useQuery({
queryKey: bioKeys.analytics(bioPageId, days),
queryFn: async () => {
const url = new URL(`/api/v1/bio/${bioPageId}/analytics`, window.location.origin);
url.searchParams.set('days', String(days));
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Analytics fetch failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as CreatorAnalytics;
},
enabled: Boolean(bioPageId),
staleTime: 5 * 60_000, // 5min — analytics is event-sourced and changes incrementally
});
}Try it
Authorization
bearer In: header
Path Parameters
Query Parameters
Response Body
application/json
application/json
application/json
application/json
curl -X GET "https://loading/api/v1/bio/string/analytics"{
"success": true,
"data": {
"totalViews": 1500,
"uniqueVisitors": 800,
"topReferrers": [
{
"referrer": "direct",
"count": 42
}
],
"deviceBreakdown": [
{
"device": "mobile",
"count": 30
}
],
"countryBreakdown": [
{
"country": "US",
"count": 25
}
],
"viewsByDay": [
{
"date": "2026-04-15",
"views": 42
}
]
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}{
"success": false,
"error": {
"code": "AUTH_UNAUTHORIZED",
"message": "Invalid credentials",
"i18nKey": "auth.login.invalid_credentials",
"i18nVars": {
"field": "email"
},
"details": [
{
"message": "email must be an email"
}
],
"correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}Source
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/creator/bio-analytics.controller.ts | 126–142 (getAnalytics) |
| DTO (response) | apps/api-core/src/modules/creator/dto/creator-response.dto.ts | 385–403 (CreatorAnalyticsDto), 349–379 (nested item DTOs) |
| Service | apps/api-core/src/modules/creator/bio-analytics.service.ts | 247–301 (getAnalytics), 229–242 (getViewsByDay) |
| Prisma model | packages/prisma/prisma/schema.prisma | BioPageView, BioPage (relation chain for ownership) |