Skip to content

Cookie Consent (DPDP 2023)

A cookie-consent banner ships across apps/site and apps/web via the shared packages/consent package. It gates PostHog (analytics, replay, and error handlers via installGlobalErrorHandlers) and Google Fonts behind explicit opt-in. The default state is no consent: no tracking, no Google Fonts, system font stack only. Copy uses DPDP vocabulary (Data Principal, purpose-by-purpose opt-in) via t(), English live and Hindi first-pass. Kwilo doesn't use Sentry; PostHog handles errors, so gating PostHog gates error capture.

How it works

User visit
  ├── Cookie present → read consent state → apply (init or skip PostHog; load or skip Google Fonts)
  └── No cookie → show banner
                   ├── Accept all  → set cookie, init PostHog, attach error handlers, load Google Fonts
                   ├── Customise   → set cookie per purpose, init what was accepted
                   └── Reject all  → set cookie (all false), system fonts, no PostHog

The banner anchors to the bottom of the viewport on apps/web (logged-in app) and shows as a center modal on apps/site (marketing). It's non-blocking: ignoring it leaves the default no-consent state. A "Manage cookies" footer link reopens the preference modal on both apps. Toggling Analytics off later calls posthog.opt_out_capturing(); replay stops on the next pageview.

Purposes

Purpose Locked What it gates
Necessary on session cookie, CSRF, auth. Never gated
Analytics opt-in PostHog initAnalytics, installGlobalErrorHandlers, replay
Preferences opt-in Google Fonts (Manrope, Fraunces); pre-consent uses the system stack

Behavior-affecting decisions

  • Default off, opt-in. DPDP treats consent as an affirmative action, so nothing non-Necessary runs until accepted. This is also accidentally GDPR-compliant for EU traffic on kwilo.ai.
  • Gate initAnalytics, don't init-then-opt-out. PostHog isn't initialised until consent is granted. Initialising and calling opt_out_capturing after the fact still writes the ph_kwilo cookie to disk, which fails an InfoSec audit. initAnalytics is idempotent, so re-granting is a no-op.
  • Google Fonts injected at runtime. The static <link rel="preconnect"> and fonts.googleapis.com tags are removed from both index.html files and injected via JS only when the Preferences purpose is true. Pre-consent CSS uses the @kwilo/ui system font stack.
  • Stale cookie schema. A missing purpose key in stored consent is treated as not consented; the banner reshows on a schema bump.

CMP

c15t 2.1.0 (@c15t/react + c15t, Apache-2.0, headless), pinned in packages/consent/package.json. Headless means the banner and preference modal are built from @kwilo/ui primitives (Dialog, Button, Switch) so the CMP belongs to the product visually. If the CMP script fails to load, a 1.5s timeout falls back to no consent.

Where it lives

  • Banner is global on every route of kwilo.ai and app.kwilo.ai; /privacy and /cookie-policy on apps/site carry the DPO contact, purpose table, and withdrawal steps.
  • packages/consent/src/: index.ts (barrel: ConsentProvider, useConsent, injectGoogleFonts, removeGoogleFonts), ConsentProvider.tsx, useConsent.ts (maps c15t consents.measurementanalytics, consents.functionalityfunctional), fonts.ts, copy.ts, constants.ts.
  • apps/web/src/main.tsx + apps/site/src/App.tsx: gate analytics + error handlers on useConsent().analytics, fonts on .functional.
  • "Manage cookies" triggers: apps/web/.../ManageCookiesLink.tsx, apps/site/.../LandingFooter.tsx.

The consent cookie cc_kwilo is stored on .kwilo.ai (cross-subdomain, SameSite=Lax; Secure):

{ "v": 1, "purposes": { "necessary": true, "analytics": false, "preferences": false }, "ts": "2026-05-22T10:00:00Z" }