Skip to content

Consent gating

Consent is enforced server-side at the collector. The browser SDK queues events while consent is 'unknown' and refuses to send while it is 'revoked', but a deliberately crafted client request that bypasses the SDK will still be rejected by the collector — provided the operator enables CONSENT_REQUIRED=true.

This page covers both layers.

  • The browser SDK defaults to defaultConsent: 'unknown'. Events queue in memory; nothing leaves the page.
  • The collector defaults to CONSENT_REQUIRED=false so out-of-the-box development just works. Operators serving EU traffic should set CONSENT_REQUIRED=true.
import { grantConsent, revokeConsent, getConsentState } from '@syntarie/tracking';
getConsentState(); // 'unknown' | 'granted' | 'revoked'
StateBehaviour
'unknown' (default)Events queue in memory. Nothing is sent.
'granted'Queued events drain. Subsequent sends go straight to the collector.
'revoked'The in-memory queue is purged. Subsequent sends are no-ops for the rest of the page.

The 'unknown''granted' transition happens when your consent UI calls grantConsent(). The 'granted''revoked' transition happens when the user opts out (calls revokeConsent() from your settings UI).

There is no 'revoked''granted' re-grant inside a single page lifetime. After revoke, the user must reload and grant fresh.

grantConsent(token?) accepts an optional opaque token. When provided, the SDK attaches it as X-Consent: <token> on every subsequent collector request:

grantConsent('eyJjaWQiOiIuLi4iLCJ0aW1lc3RhbXAiOi4uLn0=');

The token shape is yours to define — it is opaque to the SDK and the collector. Common choices:

  • A signed JSON object with (consentId, timestamp, granted_categories).
  • A short opaque id you persist alongside the user’s audit trail.

The collector persists the value on every accepted event so an audit trail is reconstructable.

Set CONSENT_REQUIRED=true on the collector. From that point:

  • Requests without X-Consent return 403 consent_required.
  • The header value is persisted alongside the event (subject to the collector’s normal length cap).

The check is wired in front of every storage write, including ingest from the Node SDK and from webhook adapters. There is no path for an event to reach storage without the header when the env var is on.

The browser SDK honours navigator.doNotTrack === '1' by default. A DNT-blocked init never installs listeners and every subsequent send is a no-op. The DNT check happens before consent — a DNT-on user does not even reach the consent state machine.

To opt out of DNT respect (e.g. you are showing a UI that explicitly asks the user even when DNT is on):

init({ siteId, host, respectDnt: false });

This is a privacy regression — gate it on a deliberate operator policy decision.

Section titled “What about pageviews queued before consent?”

The first pageview lands at init() time. If defaultConsent is 'unknown', that pageview is queued in memory along with everything else. On grantConsent() it drains in order. No event leaves the page during the 'unknown' phase.

If the user never grants consent, the queued events are dropped on pagehide — they never reach storage.

grantConsent does not retroactively un-anonymize stored events. The collector hashes the IP per event regardless of consent state; there is no “now you’ve consented, attach your real IP” path. This matches the GDPR notion that data minimization at collection time is more robust than retroactive scrubbing.

Every consent-related collector decision (granted/revoked, accepted/ rejected) is captured at the structured-log level:

level=info reason=accepted_consent consent_token_len=64 site_id=site_marketing
level=warn reason=consent_required site_id=site_marketing

Operators can pipe these to their log aggregator and assert on rejection rates as part of their privacy compliance posture.