Save Cookie Consent
Public endpoint to save user's consent choices. Works for anonymous (userId null) and authenticated. Stores under documentType COOKIE_CONSENT with current privacy version. IP captured ONLY when analytics consented (GDPR).
POST /api/v1/consent/cookies โ ๐ Public ยท Rate limit: 10 req / minute
Saves the user's cookie consent choices to a new ConsentRecord row. Works for both anonymous and authenticated users โ the bearer is optional; anonymous saves get userId: null. The version stored is the current legal.privacy_version config (so GET /consent/cookies/status will return requiresReConsent: false immediately after).
GDPR-aware IP capture: the user's IP is ONLY persisted when they explicitly consent to analytics (body.analytics === true). When analytics consent is false, the row is created with ipAddress: null regardless of what the request actually carried. The user-agent is always captured (less identifying).
No "essential" toggle in the body. The 4-category policy returns essential as required: true โ but the body only accepts analytics, marketing, functional. Essential cookies are always enabled and don't need an opt-in flag.
Append-only โ no upsert. Each call creates a new ConsentRecord row. Re-running consent doesn't replace the previous row; it appends. The GET /consent/cookies/status endpoint reads the most recent row (orderBy createdAt desc), so the newest consent wins.
Request
Body โ CookieConsentDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
analytics | boolean | โ | IsBoolean() | True = analytics cookies allowed AND IP gets stored on the row |
marketing | boolean | โ | IsBoolean() | True = marketing/personalization cookies allowed |
functional | boolean | โ | IsBoolean() | True = functional cookies allowed |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | optional | When present, the row is tied to userId. When absent, the row's userId is null (anonymous consent). |
Response
200 OK โ ApiResponseOf<ConsentSavedDto>
{
"success": true,
"data": {
"saved": true
}
}| Field | Type | Notes |
|---|---|---|
saved | boolean | Always true on 200 |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (DTO validation) | Missing fields; not a boolean |
429 | (throttle) | Rate limit exceeded (10 req/min) |
Side effects
- Resolve client IP โ priority
cf-connecting-ipstyle CDN header โ first entry ofx-forwarded-forโreq.ip. (May be undefined if none of those resolve.) - Read
legal.privacy_versionfromConfigService(default'1.0'). prisma.consentRecord.create({ id: randomUUID(), userId: userId ?? null, documentType: 'COOKIE_CONSENT', documentVersion: <currentVersion>, accepted: true, preferences: { analytics, marketing, functional }, ipAddress: body.analytics ? clientIp : null, userAgent: req.headers['user-agent'] ?? null }).acceptedis alwaystrueon this row โ the act of submitting consent IS acceptance (regardless of which categories the user toggled on/off; rejecting analytics is still "I made a choice and saved it"). The granularpreferencesJSON captures the per-category choices.- Return
{ saved: true }.
Code samples
curl -X POST https://api.bio.re/api/v1/consent/cookies \
-H 'Content-Type: application/json' \
-d '{
"analytics": true,
"marketing": false,
"functional": true
}'curl -X POST https://api.bio.re/api/v1/consent/cookies \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"analytics": true, "marketing": true, "functional": true}'type CookieConsentChoices = {
analytics: boolean;
marketing: boolean;
functional: boolean;
};
async function saveCookieConsent(
choices: CookieConsentChoices,
accessToken?: string,
): Promise<void> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
const res = await fetch('https://api.bio.re/api/v1/consent/cookies', {
method: 'POST',
headers,
body: JSON.stringify(choices),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Save consent failed'), {
code: json?.error?.code,
});
}
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useSaveCookieConsent() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (choices: CookieConsentChoices) => {
// Browser auto-attaches the auth cookie/token via shared fetch wrapper
const res = await fetch('/api/v1/consent/cookies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(choices),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Save consent failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
},
onSuccess: () => {
// Invalidate status โ banner should disappear after save
qc.invalidateQueries({ queryKey: ['consent', 'cookies', 'status'] });
},
});
}Try it
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/consent/cookies" \ -H "Content-Type: application/json" \ -d '{}'{
"success": true,
"data": {
"saved": true
}
}{
"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/content/cookie-consent.controller.ts | 87โ120 (saveConsent), 15โ19 (CookieConsentDto inline class) |
| DTO (response) | apps/api-core/src/modules/content/dto/content-public-response.dto.ts | 356โ359 (ConsentSavedDto) |
| Config | (admin-managed via ConfigService) | legal.privacy_version (default '1.0') |
| Prisma model | packages/prisma/prisma/schema.prisma | ConsentRecord (documentType: 'COOKIE_CONSENT', preferences JSON, ipAddress GDPR-conditional, userAgent always) |
Get Cookie Consent Status
Public endpoint that works for both anonymous and authenticated users. Returns the current policy version, the user's last-saved version (null for anonymous), and a flag telling the UI whether to re-show the consent banner.
Upgrade to Creator
Convert an authenticated user into a creator. Atomic โ creates CreatorProfile + BioPage + flips User.intent to CREATOR. Gated by an admin kill switch.