BIO.RE
Content

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

FieldTypeRequiredValidationNotes
analyticsbooleanโœ“IsBoolean()True = analytics cookies allowed AND IP gets stored on the row
marketingbooleanโœ“IsBoolean()True = marketing/personalization cookies allowed
functionalbooleanโœ“IsBoolean()True = functional cookies allowed
HeaderRequiredNotes
Authorization: Bearer <accessToken>optionalWhen 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
  }
}
FieldTypeNotes
savedbooleanAlways true on 200

Errors

HTTPcode / i18nKeyReason
400(DTO validation)Missing fields; not a boolean
429(throttle)Rate limit exceeded (10 req/min)

Side effects

  1. Resolve client IP โ€” priority cf-connecting-ip style CDN header โ†’ first entry of x-forwarded-for โ†’ req.ip. (May be undefined if none of those resolve.)
  2. Read legal.privacy_version from ConfigService (default '1.0').
  3. 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 }).
  4. accepted is always true on 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 granular preferences JSON captures the per-category choices.
  5. 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

POST
/api/v1/consent/cookies

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

SourcePathLines
Controllerapps/api-core/src/modules/content/cookie-consent.controller.ts87โ€“120 (saveConsent), 15โ€“19 (CookieConsentDto inline class)
DTO (response)apps/api-core/src/modules/content/dto/content-public-response.dto.ts356โ€“359 (ConsentSavedDto)
Config(admin-managed via ConfigService)legal.privacy_version (default '1.0')
Prisma modelpackages/prisma/prisma/schema.prismaConsentRecord (documentType: 'COOKIE_CONSENT', preferences JSON, ipAddress GDPR-conditional, userAgent always)

On this page