Cookie Consent (DPDP 2023)¶
Status: shipped LNO: L. The DPDP consent contract for every browser that touches
kwilo.aiorapp.kwilo.ai. Everything measurable about the product depends on what this gate lets through. Owners: @bhanu Last updated: 2026-07-13 Roles affected: every visitor and every signed-in role. The gate runs before auth. Primary routes: all routes ofkwilo.ai(apps/site) andapp.kwilo.ai(apps/web) Source of the ask: DPDP Act 2023 compliance, owner-driven. Related: Analytics and error tracking
TL;DR¶
A cookie-consent banner ships across apps/site and apps/web via the shared packages/consent package. It gates PostHog only: product analytics, session replay, and the global error handlers that route frontend errors to PostHog. The default state is no consent, so nothing but Necessary runs until the visitor accepts.
Fonts are no longer part of this. Manrope is self-hosted through @fontsource-variable/manrope and imported at app boot, so the site renders in-brand before any consent decision. The consent package no longer injects Google Fonts, and the optional Functional purpose currently stores nothing at all.
Why this exists¶
Who asked for it¶
Owner-driven, not a user request. DPDP Act 2023 treats consent as an affirmative action by the Data Principal, and Kwilo is the Data Fiduciary. Shipping PostHog without an opt-in gate was not an option.
What user pain does it solve¶
A visitor landing on kwilo.ai had no way to see, or refuse, what Kwilo measures about them. The gate makes the choice visible, purpose by purpose, and reversible.
Cost of not doing it¶
Regulatory exposure under DPDP (and, incidentally, GDPR for EU traffic that reaches kwilo.ai), plus an InfoSec audit failure the moment a tracking cookie is written before consent.
Validation¶
Not a hypothesis. The requirement is legal, not behavioural. What is a judgment call is the shape of the purpose list, and that is where the font change came from (below).
How we know it worked¶
No tracking cookie is written before an accept, and the pre-consent page renders in brand type rather than a system-font fallback. Both are directly observable in a fresh browser profile.
How it works¶
User visit
│
├── Consent stored → read state → init or skip PostHog
│
└── No consent yet → show banner
├── Accept all → store, init PostHog, attach error handlers
├── Customise → store per purpose, init what was accepted
└── Reject all → store (all false), no PostHog
Fonts load in every branch above. They are not a branch.
The banner is a bottom-anchored, non-blocking surface in both apps: a toast-style card bottom-right on apps/web, a wider bottom-centre bar on apps/site (packages/consent/src/ConsentBanner.tsx:12-27). Ignoring it leaves the default no-consent state.
Purposes¶
The category ids come from c15t and are necessary / functionality / measurement (packages/consent/src/constants.ts:3, packages/consent/src/ConsentProvider.tsx:70).
| Purpose (c15t id) | Locked | What it gates |
|---|---|---|
Necessary (necessary) |
on | Session cookie, CSRF, auth. Never gated. |
Functional (functionality) |
opt-in | Nothing today. Reserved for optional interface preferences; Kwilo stores nothing under it (packages/consent/src/copy.ts:55-59). |
Measurement (measurement) |
opt-in | PostHog init, event capture, session replay (10% sample, apps/web only), and installGlobalErrorHandlers. |
Kwilo does not use Sentry. PostHog carries error capture too, so gating PostHog gates frontend error reporting.
Behavior-affecting decisions¶
- Default off, opt-in. Nothing non-Necessary runs until accepted (
SAFE_FALLBACKinpackages/consent/src/useConsent.ts:9-13). c15t derives jurisdiction from country and has no India preset, so the provider forces the GDPR opt-in policy globally by overriding country toDE(ConsentProvider.tsx:24-30). Functionally equivalent to DPDP; no user-facing copy mentions Germany. - Gate the init, do not init-then-opt-out. PostHog is never initialised until measurement consent is granted (
apps/web/src/main.tsx:34-46,apps/site/src/App.tsx:33-49). Initialising first and opting out afterwards still writes a PostHog cookie to disk, which fails an InfoSec audit. There is noopt_out_capturingcall anywhere inpackages/analytics; withdrawal works by never mounting the provider. - Fonts are self-hosted, not consent-gated.
@fontsource-variable/manropeis a dependency of both apps (apps/web/package.json:34,apps/site/package.json:20) and is imported at boot (apps/web/src/main.tsx:17,apps/site/src/main.tsx:30). The runtime Google Fonts injection, thefonts.tshelper, and thepreconnecttags are gone. Why: a pre-consent visitor saw the site in a system font stack, which is the first impression for every new visitor and reads as an unfinished product. Self-hosting removes the third-party request entirely, so the brand renders on first paint and the Functional purpose has nothing left to gate. - Analytics is also environment-gated. Even with consent, capture is skipped on local and dev hosts (
analyticsEnabled = !!apiKey && !isLocalHost(),apps/web/src/main.tsx:23). - The CMP mounts after hydration.
apps/siteis prerendered, so the provider renderschildrenon the server and mounts the c15t machinery as a sibling only on the client (ConsentProvider.tsx:88-99). This keeps SSR and first client render identical.
CMP¶
c15t 2.1.0 (@c15t/react + c15t, headless), pinned in packages/consent/package.json. Headless means the banner and preference dialog are built from @kwilo/ui primitives, so the CMP belongs to the product visually. It runs in mode: 'offline': consent is stored client-side under the key cc_kwilo, cross-subdomain when the hostname ends in kwilo.ai (ConsentProvider.tsx:19-22, 71-74).
Banner and dialog copy is hardcoded in packages/consent/src/copy.ts, not routed through i18n: the c15t mount point sits outside the i18n provider tree and the copy proved unreliable through t(). Hindi lands when c15t's own translation API is wired (Phase 2).
Non-goals¶
- A cookie-by-cookie inventory table in the UI. The three purposes are the contract; enumerating individual cookies ages badly.
- Server-side consent storage. Offline mode is enough while the only gated vendor is PostHog.
- Gating anything that is not a third-party tracker. Fonts proved this: gating an in-brand asset punished the visitor for not deciding yet.
Rejected alternatives¶
- Init PostHog, then
opt_out_capturing(). Cheaper to wire, but the cookie is already on disk. Rejected. - Keep Google Fonts behind the Functional purpose. Kept the third-party request and left the pre-consent site in a system font. Rejected in favour of self-hosting.
- Route consent copy through i18n. Attempted; the mount point makes it unreliable (commit
d02a7cad). Hardcoded for now.
Where it lives¶
packages/consent/src/:index.ts(barrel:ConsentProvider,ConsentBanner,ConsentDialog,useConsent),ConsentProvider.tsx(maps c15tconsents.measurementtoanalyticsandconsents.functionalitytofunctional, lines 47-53),useConsent.ts,copy.ts,constants.ts.apps/web/src/main.tsx:ConsentGatedAnalytics+AnalyticsErrorHandlers, both keyed onuseConsent().analytics.apps/site/src/App.tsx:AnalyticsBootstrap, same gate, replay disabled.- "Manage cookies" reopen trigger:
apps/site/src/components/landing/LandingFooter.tsx:23(openPreferences). Gap:apps/webhas no equivalent trigger today. A signed-in user cannot reopen the dialog from inside the app.
Changelog¶
- 2026-07-13: Corrected the font claims. Consent no longer gates Google Fonts (self-hosted Manrope, commits
da6f8ef6andebb8dae4); the Functional purpose is empty; purpose ids aligned with the c15t categories actually in code; removed the stalefonts.ts/injectGoogleFontsreferences, theopt_out_capturingclaim, the/cookie-policyroute (it does not exist;/privacy-policydoes), and the "copy viat(), Hindi first-pass" claim.