npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@consenti/api

v0.4.0

Published

GDPR-style consent management platform backend — no required runtime dependencies, optional peer deps for extra storage drivers and spec-correct IAB TCF encoding

Readme

@consenti/api

GDPR-style consent management platform backend for Node.js. No required runtime dependencies — SQLite storage and everything else works out of the box; Mongo/MySQL/Postgres storage and spec-correct IAB TCF encoding are opt-in via optional peer dependencies.

npm version License Node


Why Consenti?

  • Three SQLite drivers — node:sqlite (Node 22.5+ built-in, zero install), node-sqlite3-wasm (WASM, zero compilation, optional peer dep), better-sqlite3 (native, optional peer dep)
  • One function to mount — createConsenti() returns a router you drop into any Node.js HTTP framework
  • Self-contained admin dashboard — full SPA served at your basePath, no separate deploy
  • Multi-regulation — Maintained: GDPR/UK-GDPR, CCPA/CPRA + US state laws, LGPD. Partial: IAB TCF v2.3 (spec-correct encoding needs tcf.publisherCC + the optional @iabtechlabtcf/core peer dependency) — only relevant if you monetize through programmatic/RTB ad exchanges. In development: DPDPA. Geo-routing and compliance groups extend well beyond that tier (APAC, Middle East, Africa, and more) — see ECOSYSTEM.md for per-regulation status; contributions to extend or maintain coverage are welcome

Registration is only needed for programmatic ad monetization. For first-party analytics/marketing consent, Consenti is fully compliant with zero external registration. Consenti implements the IAB TCF/GPP technical specs but is not itself a registered CMP; if you do need TCF/GPP, you must register your own cmpId with IAB Europe/MSPA independently before going live — see the TCF & GPP Registration Guide.

  • Pluggable storage — SQLite (zero-config, default) · PostgreSQL · MySQL · MongoDB
  • Companion CLI: @consenti/scanner crawls a site under none/reject-all/accept-all consent states and reports undeclared third-party trackers

Requirements

  • Node.js ≥ 20.0.0 — Node 22 or 24 LTS recommended for production

Installation

npm install @consenti/api

Database / Storage

Consenti by default comes with JSON file based storage, to get started without any installation. We have inbuilt SQLite, PostgreSQL, MySQL and MongoDB as storage adaptors, all are optional peer dependencies, only install any one of them that you need. Thats not all, you can create an adaptor for any other database you want to use by using adapter.interface.ts

Note: Default JSON file based storage is only for development and small scale deployments, for production it is recommended to use other database options.

# Databases (choose any one )

# 1. node:sqlite - Node native SQLite, 
     # needs no install on Node 22.5+

# OR 2. WASM SQLite — Node 20+, zero compilation (optional, install only if storage.driver: "node-sqlite3-wasm")
npm install node-sqlite3-wasm   

# OR 3. native SQLite — Node 20+, faster but needs a C++ toolchain (optional, install only if storage.driver: "better-sqlite3")
npm install better-sqlite3      

# OR 4. PostgreSQL (optional, install only if storage.driver: "postgresql")
npm install pg        

# OR 5. MySQL (optional, install only if storage.driver: "mysql")
npm install mysql2    

# OR 6. MongoDB (optional, install only if storage.driver: "mongodb")
npm install mongodb   

# OR 7. Create your own custom database Adaptor using "apps/api/src/storage/adapter.interface.ts"

Which SQLite driver should I use?

| Driver | Config | Node requirement | Extra install? | |---|---|---|---| | node:sqlite | driver: 'node:sqlite' | ≥ 22.5 (stable in 24) | No — Node built-in | | node-sqlite3-wasm | driver: 'node-sqlite3-wasm' | ≥ 20 | Yes — npm install node-sqlite3-wasm | | better-sqlite3 | driver: 'better-sqlite3' | ≥ 20 | Yes — npm install better-sqlite3 | | 'sqlite' | driver: 'sqlite' | ≥ 20 | Alias for better-sqlite3 |

node:sqlite is the only driver that requires no extra install — it ships with Node 22.5+. On Node 20/21, choose between node-sqlite3-wasm (pure WASM, no compilation) or better-sqlite3 (native binary, faster but may fail on Alpine/musl/ARM).

Geo resolvers - compliance routing

Consenti ships with four geo resolver options. The default requires zero installation; the others are opt-in.

Bonus: You can also supply a custom resolver function via compliance.geoDataProvider — it receives { ip, timezone, language } and must return { country: string | null, region: string | null, locale: string | null }, plus an optional complianceGroup?: string — when set, that group is used directly, skipping the country/region jurisdiction-map lookup entirely (for a provider that already carries legal-grade jurisdiction data and needs to route into an operator-defined custom group).

Note: The default ('default') and 'hosted-geoip-lite' resolvers are suitable for development and lower-traffic deployments. For high-accuracy production use, choose 'geoip' or 'maxmind'.

Which geo resolver should I use?

| Resolver | Config | Extra install? | Notes | |---|---|---|---| | default | geoDataProvider: 'default' | No | Timezone + Accept-Language heuristic; zero deps; less accurate | | hosted-geoip-lite | geoDataProvider: 'hosted-geoip-lite' | No | Calls ipinfo.io via node:https; requires outbound internet | | geoip | geoDataProvider: 'geoip' | Yes — npm install geoip-lite | Local MaxMind GeoLite2 DB via geoip-lite; fast, no outbound traffic | | maxmind | geoDataProvider: 'maxmind' | Yes — npm install maxmind | Official MaxMind SDK; most accurate; requires .mmdb file | | CountryResolverFn | geoDataProvider: myFn | Depends on your impl | Bring your own resolver — see custom example below |

# Geo resolvers (choose any one ) 

# 1. Default - language+timezone based
     # No install needed

# OR 2. Hosted Geo-IP (optional — only needed when geoDataProvider: 'hosted-geoip-lite')
     # No install needed — calls ipinfo.io using Node's built-in node:https
     # Requires outbound internet access from your server

# OR 3. Geo-IP lite (optional — only needed when geoDataProvider: 'geoip')
npm install geoip-lite

# OR 4. Maxmind (optional — only needed when geoDataProvider: 'maxmind')
npm install maxmind

# OR 5. Custom (optional — only needed when geoDataProvider: CountryResolverFn)
        # Do what it requires, just return `{ country, region, locale }` in supported format
        # (plus an optional `complianceGroup` to skip the jurisdiction-map lookup entirely)

Full Configuration

Selected values are default

import { createConsenti } from '@consenti/api'

const consenti = createConsenti({
  // ── Storage ─────────────────────────────────────────────────────────────────
  storage: {
    driver: 'json',              // 'json' | 'node:sqlite' | 'node-sqlite3-wasm' | 'better-sqlite3' | 'sqlite' | 'postgresql' | 'mysql' | 'mongodb'
    path: './consenti-data',     // base directory — Consenti creates db/, profiles/, logs/ subdirectories automatically
    
    // Optional
    uri: 'mongodb://localhost:27017/consenti', // db connection string/uri, only for (driver: 'postgresql' | 'mysql' | 'mongodb')
    database: 'consenti',        // database name when not in above uri

    // Postgres/MySQL only — connection pool tuning
    poolMax: 10,                       // max pool connections (default: 10, both drivers' own default)
    statementTimeoutMs: 30_000,        // abort a query after N ms — unset by default; a forced
                                        // default could break a legitimate long-running export
    idleInTransactionTimeoutMs: 30_000, // Postgres only: kill a connection idle inside an open transaction after N ms
  },

  // ── Auth ─────────────────────────────────────────────────────────────────────
  auth: {
    mode: 'local',               // 'local' | 'jwt' | 'oidc' | 'saml' | 'custom'
    adminEmail: '[email protected]',
    adminPassword: process.env.CONSENTI_ADMIN_PASSWORD ?? "Consenti@123",
    masterSecret: process.env.CONSENTI_ADMIN_MASTER_SECRET,  // auto-generated if omitted (sessions expire on restart)

    // OIDC (Auth0, Keycloak, Google, etc.)
    // mode: 'oidc',
    // oidc: {
    //   issuer: 'https://your-idp.example.com',
    //   clientId: 'your-client-id',
    //   clientSecret: process.env.OIDC_SECRET,
    //   redirectUri: 'https://app.example.com/consenti/admin/v1/auth/oidc/callback',
    //   claimsMapping: { email: 'email', roles: 'consenti_roles' },
    // },

    // SAML
    // mode: 'saml',
    // saml: {
    //   issuer: 'https://idp.example.com',
    //   entryPoint: 'https://idp.example.com/sso/saml',
    //   cert: process.env.SAML_CERT!,           // IdP signing cert (PEM, no headers)
    //   callbackUrl: 'https://app.example.com/consenti/admin/v1/auth/saml/acs',
    // },

    // Custom — bring your own authentication function
    // mode: 'custom',
    // validateUser: async (req: Request) => AdminUser | null,
  },

  // ── Routing ──────────────────────────────────────────────────────────────────
  basePath: '/consenti',         // URL prefix for all routes (default: '/consenti')
  dashboard: true,               // serve built-in admin SPA at basePath (default: true)

  // ── Branding ─────────────────────────────────────────────────────────────────
  branding: {
    appName: 'Consenti',              // shown in dashboard header, login page, browser tab
    appLogoPath: './logo-dark.svg',   // local file path or https:// URL; auto-served as static asset
    hidePoweredBy: false,             // true = hide "Powered by Consenti" badge
  },

  // ── Rate limiting ─────────────────────────────────────────────────────────────
  rateLimit: {
    enabled: true,
    windowMs: 60_000,            // rolling window in ms (default: 60 000)
    maxRequests: 60,             // max requests per IP per window (default: 60)
  },

  // ── Compliance ───────────────────────────────────────────────────────────────
  // Every compliance-program setting lives here — jurisdiction routing, IAB TCF, consent-record
  // signing, and data retention. (Age gate is per-profile now, not a server-wide setting — see
  // the dashboard's Profile Editor Step 1, and the widget's `profile.ageGate`.)
  compliance: {
    // Geo-based auto routing (recommended)
    type: 'auto',                // 'auto' = geo-resolve per visitor | ComplianceGroupId = one fixed group globally
    geoDataProvider: 'default',  // 'default' (timezone+language heuristic, zero-dep) | 'hosted-geoip-lite' (no install, calls ipinfo.io) | 'geoip' (optional peer dep) | 'maxmind' (optional peer dep) | CountryResolverFn
    complianceMap: 'default',    // 'default' = embedded 240-country map | a URL string (fetched, refreshed per Cache-Control/Expires, 24h fallback) | an operator-supplied ComplianceMapData object
    // complianceMap: 'https://consenti.dev/data/v1/compliance-map.json',
    // A region under overriddenRegions can set requiresSensitiveOptIn: true — a per-region carve-out
    // (not a different group) denying cpraCategory:'sensitive' cookies by default for that region only.
    // The embedded map sets this for Colorado's CPA already; only takes effect with a geoip/maxmind
    // geoDataProvider (the timezone/language heuristic never resolves a US state).

    // IAB TCF v2.3
    tcf: {
      enabled: false,
      cmpId: 123,                  // your IAB-registered CMP ID (required when enabled)
      cmpVersion: 1,               // your CMP software version
      publisherCC: 'DE',           // ISO 3166-1 alpha-2 publisher country — required for spec-correct
                                    // binary TC-string encoding; omitted → simplified fallback string
    },

    // IAB GPP (US National / "usnat" section only)
    gpp: {
      enabled: false,
      cmpId: 123,                  // must match the widget's compliance.gpp.cmpId
      cmpVersion: 1,
      mspaCoveredTransaction: false, // whether this deployment falls under MSPA signatory obligations — required, no honest default
      mspaOptOutOptionMode: 0,     // 0 = not applicable | 1 = yes | 2 = no
      mspaServiceProviderMode: 0,
    },

    // Signs server-stored consent records, ownership cookies, and parental-consent tokens, and
    // salts hashed IPs — HMAC-SHA256 over each record's core fields at create/update time,
    // hex-encoded into `signature`, checked on `GET /consent/:visitorId/verify`. Independent of
    // the widget's own cookie-signing (`core.cookieSigningKey`, which protects the visitor's
    // local cookie). Auto-generated in memory if omitted — fine for local dev, but every restart
    // invalidates outstanding ownership cookies, in-flight parental-consent tokens, and (if you
    // compare across restarts) hashed-IP continuity. Set this for production.
    dataSigningHash: process.env.CONSENTI_DATA_SIGNING_HASH,
    parentalConsentTokenTtlDays: 7, // expiry window for parental-consent tokens (only meaningful when dataSigningHash is set)

    // Data retention
    dataRetention: {
      purgeAfterDays: 365,          // delete consent records older than N days; runs nightly
      auditLogPurgeAfterDays: 730,  // optional, independent from purgeAfterDays — unset = keep forever
    },
  },

  // ── S3 profile file sync (optional) ─────────────────────────────────────────
  // When enabled, every locale JSON written to disk is also PUT to S3, plus a small
  // pointer.json per compliance group ({ profileId, version }) written on activate —
  // S3 has no symlink equivalent to the local hot-serve swap, so this pointer plays
  // the same role. Consenti's own /resolve-profile route does NOT read from S3 or
  // resolve this pointer — it's a contract for an external CDN/edge function to use
  // if you want reads to bypass the Node process entirely.
  s3Api: {
    enabled: false,
    region: 'us-east-1',
    bucketName: 'consenti-profiles',
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    // sessionToken: process.env.AWS_SESSION_TOKEN, // for temporary credentials
  },

  // ── Cache invalidation hook (optional) ──────────────────────────────────────
  // Called after every profile creation, edit, activation, deactivation, or delete.
  // isPurge=false = warm the cache at these paths; isPurge=true = purge.
  handleCache: (paths: string[], profileId: string, isPurge: boolean) => {
    // paths: array of file paths that were written or removed
    // profileId: id of the profile that triggered this write (stable across edits — see Profile Edit History)
    // isPurge: true when files were removed (deactivate/delete); false when written (activate/create)
    // Example: purge CDN edge nodes, update nginx cache keys, etc.
  },

  // ── Multi-tenant ─────────────────────────────────────────────────────────────
  multiTenant: {
    enabled: false,              // scope all data to tenant from X-Tenant-ID header
  },

  // ── Proxies & body ───────────────────────────────────────────────────────────
  trustedProxies: [],            // IP / CIDR of reverse proxies (reads X-Forwarded-For)
  maxBodySize: 1_048_576,        // request body size limit in bytes (default: 1 MB)

  // ── Plugins ──────────────────────────────────────────────────────────────────
  plugins: [],                   // ConsentiServerPlugin instances
})

Quick Start

Express

import express from 'express'
import { createConsenti } from '@consenti/api'

const app = express()

const consenti = createConsenti({
  storage: { driver: 'node:sqlite', path: './consenti-data' },
  auth: {
    mode: 'local',
    adminEmail: '[email protected]',
    adminPassword: process.env.CONSENTI_ADMIN_PASSWORD!,
  },
  dashboard: true,
})

app.use(consenti.router)

app.listen(3000, () => {
  console.log('Server → http://localhost:3000')
  console.log('Admin  → http://localhost:3000/consenti/')
  console.log('API    → http://localhost:3000/consenti/api/v1/')
})

Standalone (no framework)

import { createServer } from 'node:http'
import { createConsenti } from '@consenti/api'

const consenti = createConsenti({
  storage: { driver: 'node:sqlite', path: './consenti-data' },
  auth: {
    mode: 'local',
    adminEmail: '[email protected]',
    adminPassword: process.env.CONSENTI_ADMIN_PASSWORD!,
  },
  dashboard: true,
})

await consenti.ready  // wait for DB + bootstrap

const server = createServer(consenti.handler)
server.listen(3001, () => {
  console.log('Admin dashboard → http://localhost:3001/consenti/')
  console.log('REST API        → http://localhost:3001/consenti/api/v1/')
})

Framework Adapters

Express / Connect

app.use(consenti.router)    // as middleware (calls next() for requests outside its basePath)
// — or —
app.use(consenti.handler)   // as a terminal handler (no next())

Fastify

import Fastify from 'fastify'
import { createConsenti } from '@consenti/api'

const fastify = Fastify()
const consenti = createConsenti({ /* ... */ })

await fastify.register(consenti.fastifyHandler)
await fastify.listen({ port: 3000 })

Next.js App Router

// app/consenti/[...path]/route.ts
import { createConsenti } from '@consenti/api'
import type { NextRequest } from 'next/server'

let consenti: Awaited<ReturnType<typeof createConsenti>>

async function getConsenti() {
  if (!consenti) {
    consenti = createConsenti({
      storage: { driver: 'sqlite', path: './consenti-data' },
      auth: {
        mode: 'local',
        adminEmail: process.env.CONSENTI_ADMIN_EMAIL!,
        adminPassword: process.env.CONSENTI_ADMIN_PASSWORD!,
      },
    })
  }
  return consenti
}

async function consentiHandler(req: NextRequest) {
  const c = await getConsenti()
  return c.handleRequest(req)
}

export { consentiHandler as POST, consentiHandler as PUT, consentiHandler as DELETE, consentiHandler as PATCH, consentiHandler as GET }

Hono / Fetch-based

return consenti.honoApp.fetch(request)

First Boot

On first start with JSON, Consenti:

  1. Creates the storage directory at the configured path (see Storage Directory Structure)
  2. Runs all migrations (creates visitors, consent_records, consent_history, profiles, admin_users, audit_logs tables, and DB indexes)
  3. Creates the admin user from auth.adminEmail + auth.adminPassword
  4. Seeds the default profile (profileId: '0')

Subsequent starts run only pending migrations.

Default Profile Seeding

ProfileService exposes two idempotent helpers for seeding the 8 built-in compliance groups:

// Seed one group (skips if an active profile already exists for that group)
await profileService.seedDefaultProfile('opt-in')

// Seed all 8 groups in parallel
await profileService.seedAllDefaults()

Each seeded profile is built from the English base in @consenti/utils/profiles and automatically includes translated text overlays for German (de), Spanish (es), French (fr), and Japanese (ja) — 5 locales total per group, 40 locale files across all 8 groups.

Locale coverage per compliance group:

| Group | en | de | es | fr | ja | |-----------------------------|----|----|----|----|----| | opt-in (GDPR) | ✓ | ✓ | ✓ | ✓ | ✓ | | opt-out (CCPA) | ✓ | ✓ | ✓ | ✓ | ✓ | | opt-out-strict (CPRA) | ✓ | ✓ | ✓ | ✓ | ✓ | | opt-in-dpdpa (India) | ✓ | ✓ | ✓ | ✓ | ✓ | | opt-in-china (PIPL) | ✓ | ✓ | ✓ | ✓ | ✓ | | opt-in-brazil (LGPD) | ✓ | ✓ | ✓ | ✓ | ✓ | | general-privacy-consent | ✓ | ✓ | ✓ | ✓ | ✓ | | notice-only | ✓ | ✓ | ✓ | ✓ | ✓ |

All 5 locales — English base plus the de/es/fr/ja overlays — live in packages/utils/src/profiles/, the single source of truth for default compliance-profile content shared by profile seeding, the dashboard's "Load Defaults", and @consenti/ui's embedded fallback profile (English only there — see below).

First-Run Setup Wizard

The dashboard shows a 4-step setup wizard (#/setup) once — the first time any admin logs in after install — like a self-hosted app's installer (WordPress, phpMyAdmin, etc.):

  1. Welcome — links to documentation and the in-dashboard "How Consenti Works" page. Skippable.
  2. Your configuration — a plain, read-only view of the fully merged ConsentiServerConfig (your config + env vars merged over the built-in defaults — exactly what createConsenti resolved at boot), with secrets redacted.
  3. Default compliance profiles — an accordion of the 8 built-in compliance groups (label, description, and badges for GPC mode / TCF / CPRA categories / DPDPA disclosure), all checked by default. Continuing calls the same idempotent seedDefaultProfile/seedAllDefaults helpers described above.
  4. Confirmation — a summary of what was installed, plus a production-readiness panel that surfaces the JSON-storage-driver and default-credentials warnings createConsenti already logs to the server console (otherwise invisible to a dashboard-only admin). CTAs: Go to Dashboard / See How Consenti Works.

Gated by tenant_settings.setup_completed (per-tenant in multi-tenant mode) — set once the wizard finishes or is skipped, and never reset from the dashboard afterward. There is no "run it again" entry point; it's a one-time first-run flow, not a recurring settings screen: navigating to #/setup directly once setup is complete redirects to the dashboard instead of reopening it, and the two mutating routes (seed-profiles, complete) reject with 409 if called again after completion — enforced both in the router and on the server, not just by hiding the nav entry. Backed by 5 admin routes under /setup/* (see Admin REST API).


Storage Directory Structure

storage.path is a directory path. Consenti creates the following layout automatically:

${storage.path}/
  db/
    consenti-data.json     # JSON adapter
    consenti.db            # SQLite adapters
  profiles/
    ${tenantId}/
      ${profileId}/
        1/                 # version 1 (immutable once written)
          en.json
          fr-FR.json
          default.json     # copy of defaultLocale content (fallback)
        2/                 # version 2 written on next save
          …
      ${complianceGroup}/  # → symlink to the active profile's version dir (hot-serve path)
  logs/                    # reserved for future structured logging

Key rules:

  • Every profile save writes to a new immutable version directory under ${profileId}/${version}/
  • ${complianceGroup}/ only exists when a profile is active for that group — it's a directory symlink (junction on Windows), not a copy
  • On activate: ${complianceGroup}/ is atomically repointed at ${profileId}/${version}/ — one filesystem rename, no window where it's missing or a mix of two versions, and no leftover files from a version whose locale set has since shrunk
  • On deactivate / delete: the ${complianceGroup}/ symlink is removed (the version directory it pointed to is untouched)
  • default.json = full resolved profile for defaultLocale; used as locale fallback (303 redirect)
  • Windows: symlinks are created as directory junctions — no admin rights or Developer Mode required, but the volume must be NTFS and storage.path must stay on the same drive

Backward compat: If storage.path has a file extension (.json, .db), the parent directory is used and a deprecation warning is logged.


Public REST API

Base path: /consenti/api/v1 (change with basePath in config). No authentication required.

| Method | Path | Description | |------------|---------------------------------------------|------------------------------------------------------------------------| | GET | /profiles/:tenantId/:complianceGroup/:locale | Hot-serve — serve locale JSON directly from disk (zero DB) | | GET | /profiles/:tenantId/:profileId/:version/:locale | Serve a specific version locale JSON (dashboard preview) | | GET | /resolve-profile | Geo-resolve compliance group, return file path + locale | | GET | /profiles/:id | get profile by ID (resolved, DB-backed, default tenant, default locale)| | GET | /profiles/:id/:locale | get profile by ID & locale (resolved, DB-backed, default tenant) | | GET | /profiles/auto/:locale | Legacy: geo-resolve then return matching active profile | | POST | /consent | Submit a consent record | | GET | /consent/:visitorId | Get current consent for a visitor | | PUT | /consent/:visitorId | Update consent for a visitor | | DELETE | /consent/:visitorId | GDPR right-to-erasure — delete all visitor data | | GET | /consent/:visitorId/verify | Check if consent is still valid |

Static file serving (hot path)

GET /consenti/api/v1/profiles/default/opt-in/en

Serves ${storage.path}/profiles/default/opt-in/en.json directly from disk. No DB involved.

If the locale file doesn't exist, returns 303 See Other → .../opt-in/default (which serves default.json = defaultLocale content).

Cache headers: public, max-age=3600, stale-while-revalidate=60 — suitable for CDN caching.

Resolve profile (for compliance.type: 'auto')

GET /consenti/api/v1/resolve-profile?data=<base64-encoded-GeoHints>

For widgets using compliance.type: 'auto' — called once on page load to discover which compliance group and static profile file to serve. The widget sends browser geo hints encoded as base64 JSON:

// Widget encodes automatically via encodeGeoHints()
const data = btoa(JSON.stringify({
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  languages: navigator.languages,
  language: navigator.language,
  locale: 'fr-FR',
}))

Response:

{
  "path": "/consenti/api/v1/profiles/default/opt-in/fr-FR",
  "complianceGroup": "opt-in",
  "locale": "fr-FR",
  "found": true
}

When found is false (no active profile for this tenant/group), path is null and the widget automatically falls back to the embedded profile for the resolved complianceGroup. This means the widget works even before you have seeded profiles for every compliance group.

Legacy query params ?tz=, ?lang=, ?locale= are still accepted for backward compatibility — the data param takes priority when both are present.

Submit consent

POST /consenti/api/v1/consent
Content-Type: application/json

{
  "profileId": "my-profile-id",
  "consentJson": {
    "necessary": "granted",
    "analytics": "granted",
    "marketing": "denied"
  },
  "visitorId": "uuid-v4",
  "locale": "en",
  "gpcDetected": false,
  "source": "banner",
  "ageVerified": true,
  "parentalConsentToken": "pcon_..."
}

ageVerified/parentalConsentToken are optional — only sent by the widget when the active profile's ageGate.enabled: true (a per-profile, per-locale dashboard setting — see the @consenti/ui README's "Age Gate" section). Both are stored as-is; this endpoint doesn't validate or enforce them.

Response 201:

{
  "id": "record-uuid",
  "visitorId": "visitor-uuid",
  "profileId": "my-profile-id",
  "locale": "en",
  "consentJson": { "necessary": "granted", "analytics": "granted", "marketing": "denied" },
  "gpcDetected": false,
  "source": "banner",
  "createdAt": "2026-06-26T10:00:00.000Z",
  "updatedAt": "2026-06-26T10:00:00.000Z"
}

Verify consent

GET /consenti/api/v1/consent/visitor-uuid/verify
{ "valid": true }

// or, when stale:
{ "valid": false, "reason": "profile_changed" }
// other reasons: "consent_expired" | "hmac_invalid"

profile_changed means the profile this consent was collected against is no longer the active profile for its compliance group (a newer edit has superseded it) — every profile edit mints a new id rather than mutating in place, so an id mismatch is what signals staleness now (there is no separate version counter).

Parental consent request/resolve

See "Parental consent request/resolve (stateless hook)" under Security below for the full design and caveats.

POST /consenti/api/v1/consent/visitor-uuid/parental-consent-request
Cookie: consenti_visitor-uuid=...

{ "profileId": "my-profile-id" }
{ "token": "pcon_eyJ2aXNpdG9ySWQiOi...ab12cd34..." }
POST /consenti/api/v1/consent/parental-consent-resolve

{ "token": "pcon_eyJ2aXNpdG9ySWQiOi...ab12cd34..." }
{ "visitorId": "visitor-uuid", "profileId": "my-profile-id" }

// or, on an invalid/expired/tampered token:
// 403 { "error": "Invalid or expired token (invalid_signature)" }

Admin REST API

Base path: /consenti/admin/v1 (change with basePath in config). Every route requires Authorization: Bearer <jwt>.

Obtain a token via POST /consenti/admin/v1/auth/login.

Auth

| Method | Path | Description | |--------|-------------------------|-----------------------------------| | POST | /auth/login | Authenticate (mode 'local' only) — returns a JWT | | GET | /auth/me | Get current authenticated user (includes totpEnabled) | | POST | /auth/logout | Invalidate session | | POST | /auth/refresh | Reissue a fresh token from the current one — extends the session | | GET | /auth/oidc/authorize | Start OIDC authorization (PKCE) — 302-redirects to the IdP (mode 'oidc' only) | | GET | /auth/oidc/callback | OIDC redirect target — exchanges the code for tokens, verifies the ID token, upserts the admin user from claims, returns a JWT | | GET | /auth/saml/metadata | SAML SP metadata XML for your IdP config (mode 'saml' only) | | POST | /auth/saml/acs | SAML Assertion Consumer Service — validates the SAMLResponse, upserts the admin user, returns a JWT | | POST | /auth/totp/setup | Generate a TOTP secret for the current user and a QR-code URL (totpEnabled stays false until verified) | | POST | /auth/totp/verify | Verify a TOTP code against the pending secret and set totpEnabled: true | | POST | /auth/totp/disable | Clear the TOTP secret and set totpEnabled: false |

POST /consenti/admin/v1/auth/login
Content-Type: application/json

{ "email": "[email protected]", "password": "your-password" }
{ "token": "eyJhbGciOiJIUzI1NiJ9..." }

Session length: admin tokens are valid for 30 minutes from issuance, not a flat expiry from login — the dashboard calls POST /auth/refresh on user activity (mouse/keyboard/scroll/touch) to extend it another 30 minutes, and proactively logs the user out the moment 30 minutes pass with no activity at all, rather than leaving the session to silently lapse in the background.

SSO (OIDC / SAML): set auth.mode: 'oidc' (with auth.oidc: { issuer, clientId, clientSecret, redirectUri, claimsMapping? }) or auth.mode: 'saml' (with auth.saml: { issuer, entryPoint, cert, callbackUrl }) — see the auth block in Full Configuration. Both flows upsert (create-if-missing) an admin user by email on first successful login and, for OIDC, assign roles from claimsMapping.roles when the IdP's claim matches an existing role name; there is no separate admin-created-user requirement for SSO logins. POST /auth/login (local email/password) returns 400 when auth.mode isn't 'local'.

TOTP (per-user MFA): each admin user can independently enable TOTP via setup → scan the QR code → verify. This is opt-in per user and layered on top of whichever auth.mode is active; it is not currently enforced as a required second factor on POST /auth/login itself — treat it as available account-hardening, not a login gate, until wired into the login flow.

Profiles

| Method | Path | Description | |------------|----------------------------------|-------------------------------------------------------------------------| | GET | /profiles | List all profiles | | GET | /profiles?summary=1 | List profiles as ProfileSummary[] (no blob, with template names) | | POST | /profiles | Create a profile — 422 on missing mandatory content or compliance errors; { conflict, requiresChoice: true } when an active profile exists on the same complianceGroup | | GET | /profiles/:id | Get a profile | | PUT | /profiles/:id | Edit a profile — mutates the row in place, increments version; returned id always matches the URL's. Same mandatory-content + compliance validation + conflict detection | | DELETE | /profiles/:id | Delete a profile | | POST | /profiles/:id/copy | Duplicate a profile as a new, always-inactive profile (version resets to 1). Optional { name } body, defaults to Copy of {name} | | POST | /profiles/:id/activate | Activate a profile — writes locale JSON files to ${complianceGroup}/ | | POST | /profiles/:id/deactivate | Deactivate a profile — removes ${complianceGroup}/ locale files | | GET | /profiles/:id/versions | List every version snapshot on disk, newest first ({ version, createdAt, locales[] }[]) | | GET | /profiles/:id/versions/:entryId | Read a specific version's locale file, :entryId is its version number (query: ?locale=en) | | GET | /profiles/archived | List profile-id directories on disk with no matching DB row (deleted profiles whose version-snapshot history survives) — ArchivedProfileSummary[] | | POST | /profiles/validate | Validate a consent template (cookies + categories) against a compliance group (no save) | | GET | /compliance-coverage | Active profile (or null) per compliance group — powers coverage panel |

Profile Save Conflict Detection

Only one profile per complianceGroup can be active at a time. If you attempt to create or update a profile with isActive: true and another active profile already exists for the same complianceGroup, the backend returns 200 (not an error) with:

{
  "conflict": { "id": "existing-profile-uuid", "name": "GDPR Profile v1" },
  "requiresChoice": true
}

Re-submit the original request with a choice field to resolve:

{ "choice": "deactivate" }   // deactivate the conflicting profile, then save this one as active
{ "choice": "inactive" }     // save this profile as inactive (no conflict)

The dashboard profile editor shows a three-button popup when requiresChoice: true — "Deactivate existing and activate this one", "Save as inactive", and "Cancel".

ProfileSummary shape

{
  id: string
  name: string
  defaultLocale: string
  complianceGroup: ComplianceGroupId | null
  customComplianceGroup: string | null
  isActive: boolean
  consentTemplateName: string | null
  uiTemplateName: string | null
  createdAt: string
  updatedAt: string
}

Consent Templates

A Consent Template merges what used to be two concerns — the parameter (cookie) list and its categories — into one entity. Categories own legal basis; a parameter's effective legal basis is derived from whichever category lists it (a parameter must belong to exactly one category).

| Method | Path | Description | |------------|---------------------------------------------|----------------------------------------------------------| | GET | /consent-templates | List consent templates | | GET | /consent-templates/:id | Get a consent template | | POST | /consent-templates | Create a consent template — { name, cookies: CookieMap, categories: CategoryMap } | | PUT | /consent-templates/:id | Update a consent template — 422 if the change breaks compliance for a dependent profile | | DELETE | /consent-templates/:id | Delete a consent template — 422 if any active profiles use it | | POST | /consent-templates/:id/copy | Duplicate a consent template | | GET | /consent-templates/:id/profile-usage | List profiles using this template (ProfileSummary[]) |

CookieMap/CategoryMap are keyed by id (Record<string, Cookie> / Record<string, Category>) — the id is the map key, not a field on the value.

Consent Template Safety Guards

Deletion guard: DELETE /consent-templates/:id returns 422 with { profiles: ProfileSummary[] } when one or more active profiles reference the template. Deactivate those profiles first, then delete.

Compliance guard: When updating a template (PUT) with new cookies and/or categories, the backend re-runs compliance validation (using the new set) against every profile currently using the template. If any profile's complianceGroup rules would be violated, the response is 422 with:

{
  "blockingProfiles": [{ "id": "...", "name": "...", "complianceGroup": "opt-in" }]
}

Profiles listed in blockingProfiles must be updated (switch template or change complianceGroup) before the change can be saved.

UI Templates

| Method | Path | Description | |------------|---------------------------------------------|----------------------------------------------------------| | GET | /ui-templates | List UI templates | | GET | /ui-templates/:id | Get a UI template | | POST | /ui-templates | Create a UI template | | PUT | /ui-templates/:id | Update a UI template | | DELETE | /ui-templates/:id | Delete a UI template | | POST | /ui-templates/:id/copy | Duplicate a UI template | | GET | /ui-templates/:id/profile-usage | List profiles using this template (ProfileSummary[]) |

Analytics

| Method | Path | Description | |--------|-------------------------|----------------------------------------------------| | GET | /analytics/opt-in | Opt-in rate stats aggregated by locale and date |

Query params: tenantId, profileId, complianceGroup, from (ISO date), to (ISO date), locale.

{
  "total": 12400,
  "granted": 6800, "denied": 4100, "managed": 1500,
  "grantedPct": 54.8, "deniedPct": 33.1, "managedPct": 12.1,
  "byLocale": { "en": { "total": 8000, "granted": 4400 } },
  "byDate": [{ "date": "2026-07-01", "total": 400, "granted": 220 }]
}

Consents

| Method | Path | Description | |----------|----------------------------------|---------------------------------------------| | GET | /consents | List consent records (paginated, filterable)| | GET | /consents/:visitorId | Get consent record for a visitor | | GET | /consents/:visitorId/history | Get consent change history |

Query params for GET /consents: page, limit (max 500), profileId, from, to, q (prefix search across visitorId, profileId, locale, source — matches from the start of the field, not a substring anywhere in it). Response: { items, total, page, limit }. List rows omit consentJson/parentalConsentToken/tcfString/signature — fetch GET /consents/:visitorId for the full record.

Visitors

| Method | Path | Description | |--------|----------------|-------------------------------------| | GET | /visitors | List visitor records (paginated) |

Query params for GET /visitors: page, limit (max 500), from, to, q (prefix search across visitorId, country). Response: { items, total, page, limit }.

IPs are never stored raw — masked (last IPv4 octet / last 80 bits of IPv6 zeroed), salted with compliance.dataSigningHash, and SHA-256 hashed into the ipHash field.

Users & Roles

| Method | Path | Description | |------------|-----------------------------------|------------------------------------------------------| | GET | /users | List admin users | | GET | /users/:id | Get an admin user | | POST | /users | Create an admin user | | PUT | /users/:id | Update an admin user | | DELETE | /users/:id | Delete an admin user | | POST | /users/:id/roles | Assign a role to a user | | DELETE | /users/:id/roles/:roleId | Revoke a role from a user | | GET | /roles | List roles | | POST | /roles | Create a role | | PUT | /roles/:id | Update a role | | DELETE | /roles/:id | Delete a role | | GET | /roles/:id/permissions | Get permissions for a role | | POST | /roles/:id/permissions | Assign a permission to a role | | DELETE | /roles/:id/permissions/:permId | Revoke a permission from a role | | GET | /permissions | List all available permissions |

User allowedTenants

Admin users can be scoped to specific tenants. When allowedTenants is set, the user can only see and manage data for those tenants:

// POST /admin/users or PUT /admin/users/:id
{
  "name": "APAC Manager",
  "email": "[email protected]",
  "password": "...",
  "allowedTenants": ["tenant-uuid-1", "tenant-uuid-2"]  // empty = access to all tenants
}

Users with role: 'superadmin' always have access to all tenants regardless of allowedTenants.

Enforcement: The auth middleware passes allowedTenants to every list query. A user whose allowedTenants does not include the tenant being queried receives an empty result set (not a 403), so pagination works normally. A user with an empty allowedTenants array has no restrictions. Routes for individual resources (GET /consents/:visitorId, GET /consents/:visitorId/history) return 403 Forbidden instead of an empty result when the tenant is not in allowedTenants.

The Users dashboard page includes an Edit button on each user row (visible to users with user:update permission) that opens a modal for updating allowedTenants without changing the user's password or role.

API Keys

| Method | Path | Description | |------------|----------------------------|------------------------------------------------------------------| | GET | /apikeys | List API keys | | POST | /apikeys | Create an API key (raw key returned once — save it!) | | DELETE | /apikeys/:id | Revoke an API key (soft — can be undone with reactivate) | | POST | /apikeys/:id/reactivate | Re-enable a revoked key — same hash, no new secret to distribute | | DELETE | /apikeys/:id/permanent | Permanently delete the key row — cannot be undone |

Settings

Tenant-wide dashboard settings — the Public and Admin API origin allowlists shown on the API Config page (split into two panels, one per API).

| Method | Path | Description | |----------|-------------|-------------------------------------| | GET | /settings | Get this tenant's settings ({} if none set yet) | | PATCH | /settings | Update settings (partial — only sends fields being changed) |

// GET /consenti/admin/v1/settings
{ "allowedOrigins": ["https://example.com"], "adminAllowedOrigins": ["https://dashboard.example.com"] }

// PATCH /consenti/admin/v1/settings
{ "allowedOrigins": ["https://example.com", "https://foo.example.com"] }

allowedOrigins is the fallback used by POST /consenti/api/v1/consent's origin check when the specific profile being submitted to doesn't set its own profileJson.allowedOrigins (profile-level, if present, always takes precedence). The public API has no auth token, so this is its only access gate.

adminAllowedOrigins is an additional CORS-layer check on top of Bearer-token auth for browser-originated /consenti/admin/v1/* requests (server-to-server callers without an Origin header are unaffected). Unauthenticated static assets (widget.js/widget.css) are exempt — they must stay embeddable from any origin. Be careful: if you configure this, include the dashboard's own origin or you'll lock yourself out of the dashboard along with everyone else.

Both lists accept full origins (https://example.com) or a wildcard subdomain pattern (*.example.com). Empty/unset means no restriction — every origin is allowed.

Setup Wizard

Backs the dashboard's one-time first-run wizard (see First-Run Setup Wizard). All routes require settings:update, same as Settings above.

| Method | Path | Description | |--------|-------------------------------|--------------------------------------------------------------------| | GET | /setup/status | { completed: boolean } — whether this tenant has finished/skipped the wizard | | GET | /setup/config | Resolved ConsentiServerConfig (secrets redacted) plus usingJsonStorage/usingDefaultCredentials readiness flags | | GET | /setup/compliance-groups | The 8 built-in compliance groups with label/description/regulation metadata | | POST | /setup/seed-profiles | { groups: string[] } — seeds default profiles for the given groups (subset of the 8 ids; 400 on an unknown id) | | POST | /setup/complete | Marks the wizard complete — called on both finish and skip; never reset from the dashboard afterward |

Stats & Reporting

| Method | Path | Description | |--------|-----------------------------------|------------------------------------------| | GET | /stats/overview | Total consents, visitors, GPC count | | GET | /stats/timeline | Daily consent counts (?days=30) | | GET | /stats/categories | Per-category grant/deny breakdown | | GET | /stats/countries | Consent count by country | | GET | /stats/gpc | GPC detection statistics | | GET | /export/consents | Export consent records (CSV or JSON) | | GET | /export/consents/xlsx | Export as XLSX | | GET | /export/audit | Export audit log (CSV or JSON) | | GET | /export/translations/:profileId | Export all translatable fields as CSV |

Audit Log

| Method | Path | Description | |--------|--------------|---------------------------------------------------------| | GET | /audit | Paginated audit log; filter by action, resourceType, date range, q (prefix search across action, resourceType, resourceId, userId). Response: { items, total, page, limit }; list rows omit oldData/newData — fetch GET /audit/:id for the full entry. | | GET | /audit/:id | Full audit log entry (oldData/newData) — list rows omit these for performance; fetch on demand |

Multi-Tenant

| Method | Path | Description | |------------|------------------|----------------------| | GET | /tenants | List tenants | | POST | /tenants | Create a tenant | | PUT | /tenants/:id | Update a tenant | | DELETE | /tenants/:id | Delete a tenant |

Only active when multiTenant.enabled: true.

IAB TCF

| Method | Path | Description | |--------|-----------------------------|----------------------------| | GET | /tcf/vendors | List IAB TCF vendors (paginated, ?q= name filter) | | GET | /tcf/purposes | List IAB TCF purposes | | GET | /tcf/registration-status | Draft/confirmed diff plus a live IAB CMP-List lookup for cmpId (?refresh=true bypasses the 7-day cache) — powers the dashboard's TCF Registration panel | | POST | /tcf/confirm-registration | { acknowledge: true } — records the confirmation as a hash of cmpId/cmpVersion/publisherCC (never the raw values); 409 if IAB's CMP List shows cmpId deregistered, 404 if not found yet |

Only active when tcf.enabled: true. cmpId/cmpVersion here must match the widget's own compliance.tcf config (@consenti/ui README, "TCF" section). The widget installs window.__tcfapi itself; these admin-only routes are for the dashboard's vendor/purpose pickers and registration-status panel, not consumed by the public widget.

Set tcf.publisherCC (your ISO 3166-1 alpha-2 publisher country code) and install the optional @iabtechlabtcf/core peer dependency to get spec-correct binary TC-string encoding on consent records; without both, tcfString falls back to the simplified base64url-JSON format the widget also uses. See the TCF & GPP Registration Guide for details.

IAB GPP (US National)

| Method | Path | Description | |--------|------------------------------|----------------------------| | GET | /gpp/registration-status | Draft/confirmed diff for cmpId/cmpVersion — powers the dashboard's GPP Registration panel | | POST | /gpp/confirm-registration | { acknowledge: true } — records the confirmation as a hash of cmpId/cmpVersion |

Only active when gpp.enabled: true. Unlike TCF, IAB publishes no CMP-List equivalent for GPP, so confirmation here is self-attestation only (no external registry to validate cmpId against) — the checkbox and hash-drift detection (re-arms when cmpId/cmpVersion changes) are the whole mechanism. cmpId/cmpVersion must match the widget's own compliance.gpp config (@consenti/ui README, "GPP" section). Install the optional @iabgpp/cmpapi peer dependency for spec-correct binary GPP-string encoding; without it, gppString falls back to a base64url-JSON payload, same fallback shape as the widget's own stub.


Admin Dashboard

Served at {basePath}/ when dashboard: true.

| Section | Description | |---------------------|--------------------------------------------------------------------------| | Dashboard | Consent overview, timeline chart, country breakdown, GPC stats | | Reports | Opt-in trend and category/locale breakdowns, date-range filter, requires stats:view | | Profiles | Create / edit / copy / delete / activate / deactivate consent profiles | | Profile History | Edit history viewer — browse every past snapshot in a profile's lineage | | Consent Templates | Reusable parameter + category definitions (blank by default + Load Defaults) | | UI Templates | Reusable banner + modal layout settings | | Consents | Browse, filter, search, paginate, export consent records; per-visitor history; row click opens a detail modal with signature/consent-data breakdown | | Visitors | Visitor list with geographic data; search, paginate; row click opens a detail modal including proof-of-notice history | | Users | Admin user management with allowed-tenant scoping; search; local-auth password reset (super_admin only) | | Roles | RBAC roles and fine-grained permission assignment | | Sites | Multi-tenant site management (superadmin only) | | TCF Vendors | IAB Global Vendor List | | Audit Log | Append-only log of all admin actions, never deleted by Consenti; search, paginate; row click opens a detail modal with old/new data diff | | Settings / API | API keys, branding, OpenAPI docs (superadmin only) | | Setup Wizard | One-time first-run welcome / config / default-profiles / confirmation flow — see First-Run Setup Wizard |

Dashboard RBAC

  • Sites and API sections are visible only to users with the super_admin role
  • TCF Vendors is visible to all authenticated users
  • Reports requires the stats:view permission (granted by default to super_admin, admin, compliance_officer, and viewer roles)
  • Password reset in the Users edit modal is shown only to super_admin users, and only when auth.mode === 'local' (JWT/OIDC/SAML/custom auth manage credentials outside Consenti, so there is nothing to reset here)

Profile Creation Wizard

Step 1 — Profile metadata:

| Field | Type | Description | |---------------------|------------------------------------------------------|-----------------------------------------------------------------------------| | name | string | Human-readable profile name | | defaultLocale | string (BCP 47) | Locale served when visitor locale is unavailable; written as default.json | | complianceGroup | ComplianceGroupId | Regulation group for geo-routing (e.g. opt-in, opt-out-strict) | | customComplianceGroup | string (lower-kebab-case) | Required when complianceGroup is unset ("None / Custom") — it's the identifier the widget's compliance.type config uses to fetch this profile (see Public REST API hot-serve path). Drives activation, deactivation, and "one active profile per group" conflict detection the same way complianceGroup does; no COMPLIANCE_GROUPS validation rules or GPC defaults apply to it, since none exist for a free-form name. | | gpcMode | 'ignore' \| 'honor' \| 'strict' | GPC signal handling. Overrides group default. | | expiryDays | number (default 365) | Days until stored consent expires and the visitor is asked again. Profile-wide — replaces the old per-parameter expiry field. | | darkMode | boolean | Enable dark mode in the consent banner | | hidePoweredBy | boolean (default true) | Hide "Powered by Consenti" branding link. Defaults to checked/hidden in the dashboard, matching the widget's own default for a profile that never sets this field. | | allowReceipt | boolean | Allow visitors to download a PDF consent receipt | | allowedOrigins | string[] | Allowlisted domains for CORS on this profile's consent endpoints | | complianceConfig | Record<string, string> | Per-compliance extra config (e.g. DPDPA data fiduciary name). Only shown when the selected complianceGroup requires it; otherwise omitted. | | showFooterMetadata | boolean | Show a metadata strip in the banner and modal footer with: Consent ID (visitor UUID), Consent Date, Profile Version, and a "Privacy Settings" link. | | enhanceAccessibility | boolean | Apply WCAG 2.1 AA enhancements: 44 px min button height, 3 px focus rings. Adds .consenti--enhanced-a11y class to the widget root. |

Step 2 — Consent Template: Define parameters (cookies) and the categories that own their legal basis, edited together. Clicking Next at the bottom of Step 2 calls POST /admin/profiles/validate with the selected template's cookies + categories and the chosen complianceGroup:

  • Compliance errors (red panel): block advancing to Step 3 until resolved (e.g. a category has no legalBasis set, or a marketing-purpose parameter is assigned to a mandatory category).
  • Compliance warnings (amber panel): show an acknowledgment checkbox; the user must check it before advancing. Warnings do not block save — they surface potential issues (e.g. preGrant: true on a strict opt-in profile).

Parameter fields:

| Field | Type | Description | |---------------------|-------------------------------------------------|---------------------------------------------------------------------------| | id | string (map key) | Unique identifier referenced in button arrays and category cookies[] | | purpose | 'necessary' \| 'functional' \| 'preferences' \| 'analytics' \| 'marketing' | What the parameter is for. Required — parameter IDs are free-form, so the purpose is what integrations and compliance checks rely on. Selecting a purpose pre-fills listenGpc and cpraCategory (still editable). Known Google Consent Mode IDs (ad_storage, analytics_storage, …) auto-detect their purpose. | | listenGpc | boolean | Auto-denied when GPC signal is active | | preGrant | boolean | Pre-grant this parameter's default consent. Only editable when its category's legalBasis === 'consent' — mandatory/legitimate_interest categories are already effectively pre-granted, so the checkbox is locked checked for those. | | tcfVendorId / tcfPurposes | number / number[] | IAB TCF vendor + purpose IDs associated with this parameter | | cpraCategory | 'sale' \| 'sharing' \| 'sensitive' | CPRA data category |

Category fields (own the legal basis for every parameter listed in cookies[] — a parameter must belong to exactly one category):

| Field | Type | Description | |---------------------|--------------------------------------------------------|--------------------------------------------------------------------| | id | string (map key) | Unique identifier | | legalBasis | 'mandatory' \| 'consent' \| 'legitimate_interest' | Legal basis for every parameter in cookies[] — replaces the old per-parameter legalBasis field | | cookies | string[] | Parameter IDs belonging to this category |

Purpose defaults (applied to the parameter when a purpose is selected, all overridable; the category's legalBasis is authored separately):

| purpose | Suggested category legalBasis | listenGpc | cpraCategory | |---------------|-----------------------------------|-------------|----------------| | necessary | mandatory | false | — | | functional | legitimate_interest | false | — | | preferences | legitimate_interest | false | — | | analytics | consent | true | — | | marketing | consent | true | sharing |

Per-profile parameter overrides: ProfileConfig.cookiesOverride?: Record<string, Partial<Cookie>> lets a profile tune specific fields (e.g. preGrant) of a template-authored parameter without forking the whole Consent Template — deltas merge onto the resolved cookies map by parameter id at resolve time (GET /profiles/:tenantId/:complianceGroup/:locale and friends); a delta for a parameter id not present in the template is ignored. categoriesOverride/uiOverride exist on the type for a future phase but aren't applied yet.

In the Step 2 parameter table, Pre Grant is editable per-profile (writes/removes a cookiesOverride delta) except for parameters whose category isn't legalBasis: 'consent' (locked checked-and-disabled, same rule as template authoring). Selecting a compliance group or Consent Template auto-defaults it: opt-out/opt-out-strict force it off (with an amber warning), every other group forces it on for parameters the template didn't already pre-grant — only where that differs from the template's authored value.

Step 3 — UI Template: Configure banner and modal layout. New UI templates start blank (no prefilled buttons). Click Load Defaults in the amber callout to populate a sensible starter structure.

  • Main Banner / GPC Banner: position, overlayOpacity, showClose, headingTag, buttons
  • Preference Modal: position, overlayOpacity, showClose, persistent, buttons — categories are no longer edited here; they come from the Consent Template selected in Step 2.
  • Button id: a machine identifier (e.g. accept-all), not display text — UI templates own layout/behavior only. The visitor-facing label for each button is authored per-locale in Step 4, shown there as id (action) so authors know which button they're labeling.
  • Button type: primary | secondary | text | reject | submit | manage | close
  • Button cookies: * (grant all) · ! (deny all) · comma-separated IDs

Additional UI template fields:

| Field | Scope | Type | Description | |-------|-------|------|-------------| | stackButtonsOnBreakpoint | Main Banner, GPC Banner | number | Below this viewport width (px) banner buttons stack vertically and stretch full-width. 0 or absent = disabled. Default when enabled: 576. | | trapFocus | Preference Modal | boolean | Confine Tab / Shift+Tab keyboard navigation within the modal while it is open. Escape closes and restores prior focus. |

Step 4 — Content: Enter localised copy with live preview:

  • Main