@consentify/core
Headless consent state management with cookie storage, policy versioning, typed events, multi-tab sync, and Google Consent Mode v2.
@consentify/core is the headless foundation of the Consentify SDK. It handles consent storage (cookie and/or localStorage), policy versioning via content hashing, typed events, multi-tab sync via BroadcastChannel, and optional Google Consent Mode v2 wiring.
Zero dependencies, SSR-safe, ~2KB gzipped.
Installation
npm install @consentify/coreCreating an Instance
import { createConsentify } from '@consentify/core';
export const consentify = createConsentify({
policy: {
categories: ['analytics', 'marketing', 'preferences'] as const,
},
});Options
createConsentify({
policy: {
categories: ['analytics', 'marketing'] as const, // required
identifier: 'v2', // optional explicit policy version
},
mode: 'opt-in', // 'opt-in' (GDPR, default) | 'opt-out' (CCPA)
storage: ['cookie', 'localStorage'], // storage order; default: ['cookie']
consentMaxAgeDays: 180, // expire consent after N days (default: never)
expirationWarningDays: 30, // emit 'expiring' event N days before expiry
cookie: {
name: 'consentify', // default: 'consentify'
maxAgeSec: 31_536_000, // default: 1 year
sameSite: 'Lax', // 'Lax' | 'Strict' | 'None'
secure: true, // default: true (forced to true for 'None')
path: '/',
domain: undefined,
},
});Consent State
All read methods return a discriminated union:
type ConsentState<T extends string> =
| { decision: 'unset' }
| {
decision: 'decided';
snapshot: {
choices: Record<'necessary' | T, boolean>;
givenAt: string; // ISO timestamp
policy: string; // policy hash
};
};Client API (flat, top-level)
The instance exposes a flat API for browser use:
get()
const state = consentify.get();
if (state.decision === 'unset') {
// no consent given yet - show banner
}
if (state.decision === 'decided') {
console.log(state.snapshot.choices);
}set(choices)
consentify.set({ analytics: true, marketing: false });acceptAll() / rejectAll()
consentify.acceptAll();
consentify.rejectAll();clear()
Remove consent. get() then returns { decision: 'unset' }.
consentify.clear();isGranted(category)
if (consentify.isGranted('analytics')) {
loadAnalytics();
}subscribe(callback)
Low-level subscription; fires on any consent change (set, clear, cross-tab sync).
const unsubscribe = consentify.subscribe(() => {
const state = consentify.get();
console.log('consent changed:', state);
});guard(category, onGrant, onRevoke?)
One-shot grant handler with optional revoke cleanup. Returns a disposer.
consentify.guard('analytics',
() => loadAnalyticsScript(),
() => unloadAnalyticsScript(),
);getProof()
Returns a signed consent snapshot for audit logs. null if decision === 'unset'.
const proof = consentify.getProof();
// { policy, givenAt, choices, signature }Typed Event System
consentify.on('change', ({ from, to, timestamp }) => {
console.log('changed', { from, to });
});
consentify.on('clear', ({ timestamp }) => {
console.log('consent cleared');
});
consentify.on('expiring', ({ expiresAt, daysRemaining }) => {
console.log(`expires in ${daysRemaining} days`);
});
// One-shot:
consentify.once('change', () => console.log('first change'));on/once return unsubscribe functions. They are preferred over subscribe when you need typed payloads (from/to snapshots, timestamps).
Server API (SSR)
For reading consent in server code, pass the Cookie header:
import { consentify } from '@/lib/consentify';
export async function Page({ headers }: { headers: Headers }) {
const state = consentify.get(headers.get('cookie') ?? '');
if (state.decision === 'decided' && state.snapshot.choices.analytics) {
// render server-side analytics scripts
}
}get(cookieHeader), set(choices, cookieHeader), clear(cookieHeader) all accept a cookie string and return a Set-Cookie header string for set/clear, or a ConsentState for get.
The same methods are also available as consentify.server.get(cookieHeader) / consentify.server.set(...) if you prefer explicit server namespacing.
Policy Versioning
The SDK generates a deterministic FNV-1a hash of your category keys (or uses your explicit policy.identifier). The hash is stored in the consent cookie.
When you add or remove categories, the hash changes. Existing cookies with the old hash are treated as { decision: 'unset' } - the visitor is prompted again.
consentify.policy.identifier; // e.g. 'a1b2c3d4'
consentify.policy.categories; // readonly ['analytics', 'marketing']Multi-tab Sync
Consent changes sync across browser tabs automatically via BroadcastChannel. Subscribers in all open tabs fire on every set/clear.
Google Consent Mode v2
import { createConsentify, enableConsentMode, defaultConsentModeMapping } from '@consentify/core';
export const consentify = createConsentify({
policy: { categories: ['analytics', 'marketing', 'preferences'] as const },
});
enableConsentMode(consentify, {
mapping: defaultConsentModeMapping,
waitForUpdate: 500, // optional; delays gtag events by N ms to let consent resolve
});enableConsentMode bootstraps window.dataLayer and window.gtag if absent, then calls gtag('consent', 'default', ...) with all types denied, followed by gtag('consent', 'update', ...) whenever consent changes.
Provide a custom mapping to connect your categories to Google consent types:
enableConsentMode(consentify, {
mapping: {
analytics: ['analytics_storage'],
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
preferences: ['functionality_storage', 'personalization_storage'],
},
});Debug Adapter
Tree-shakeable helper that logs every event:
import { enableDebug } from '@consentify/core';
enableDebug(consentify); // logs via console.log
enableDebug(consentify, {
onLog: (msg, event) => logger.info({ event }, msg),
});Script Tag (IIFE Bundle)
@consentify/core ships an IIFE bundle at dist/consentify.iife.min.js for script-tag usage without a bundler. Import via a <script> tag and access window.Consentify (separate namespace from the hosted widget).
TypeScript
Category keys are inferred from policy.categories:
const consentify = createConsentify({
policy: { categories: ['analytics', 'marketing'] as const },
});
const state = consentify.get();
if (state.decision === 'decided') {
state.snapshot.choices.analytics; // boolean
state.snapshot.choices.unknown; // type error
}