Install
Add the script as the first script in your page's head, with your site key from the dashboard's Install page. Scripts parsed before it can't be held, so order matters.
Every page, in <head>
<head>
<script
src="https://d34dtur9cre43o.cloudfront.net/sdk/v1/plain-consent.js"
data-site="pk_live_YOUR_SITE_KEY"
></script>
<!-- everything else after -->
</head>Using a framework? The same tag works in every one, and it's the only way to hold trackers written into your HTML. See Frameworks for reading consent from your own code.
The script reads these attributes from its own tag:
- data-siteType: string
- Your public site key. Required.
- data-apiType: URL
- API base for receipts and events. Defaults to the script's origin + /api/v1.
- data-config-urlType: URL
- Where to fetch the published config. Defaults to {api}/config/{site key}.
- data-debugType: flag
- Allows ?plain_country= and ?plain_region= to override location for testing. Always on for localhost.
| Name | Type | Description |
|---|---|---|
| data-site | string | Your public site key. Required. |
| data-api | URL | API base for receipts and events. Defaults to the script's origin + /api/v1. |
| data-config-url | URL | Where to fetch the published config. Defaults to {api}/config/{site key}. |
| data-debug | flag | Allows ?plain_country= and ?plain_region= to override location for testing. Always on for localhost. |
Frameworks
Plain Theory works with React, Next.js, Vue, Nuxt, Svelte, SvelteKit, Angular and any other stack that renders HTML. Add the script tag from Install, then read and change consent from your components with the JavaScript API. Nothing to install from npm.
import { useEffect, useState } from "react";
// true once the visitor allows analytics
export function useAnalyticsAllowed() {
const [allowed, setAllowed] = useState(false);
useEffect(() => {
const update = (e) => setAllowed(!!e.detail.categories.analytics);
window.addEventListener("plainconsent:change", update);
window.PlainConsent = window.PlainConsent || [];
window.PlainConsent.push((pc) => setAllowed(!!pc.get().categories.analytics));
return () => window.removeEventListener("plainconsent:change", update);
}, []);
return allowed;
}Categories are essential (always allowed), functional, analytics and marketing. A script that has already run can't be unloaded, so a later withdrawal takes effect from the next page view.
Coming soon: typed packages. Packages with hooks, components and types for React, Vue, Svelte and Angular aren't published yet. Until they are, the script tag and the JavaScript API do everything they will.
WordPress
Works with WordPress: paste the script into your theme header or a header-code plugin. Banner design, regions and languages stay in the dashboard.
- Paste the tag from Install right after
<head>in your theme'sheader.php. Use a child theme so theme updates don't remove it. - Or use a header-code plugin such as WPCode: add a snippet, choose Site wide header and give it the highest priority, so it loads before other scripts.
- To hold a script another plugin adds, mark it as described in Block trackers. Known trackers are held automatically.
Block trackers
Change the type of any script that needs consent to text/plain and name its category in data-consent. It stays inert until the visitor agrees to that category, then runs exactly once.
<script type="text/plain" data-consent="analytics"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<script type="text/plain" data-consent="marketing">
fbq('init', '000000000000');
</script>Scripts and iframes added later are held automatically when their src matches a known tracker or one on your Trackers page. Google Analytics, Hotjar, Microsoft Clarity, Meta Pixel, LinkedIn Insight, TikTok Pixel and Google Ads are recognised before your config even loads. Categories are functional, analytics and marketing; essential is never blocked.
Regions
The config response carries the visitor's country in x-plain-country (and state in x-plain-region) from the CloudFront edge. The script picks a notice from it:
- EU, EEA, UK, CHType: gdpr
- Opt-in. Nothing non-essential runs until the visitor accepts.
- US + CAType: ccpa
- Opt-out. Trackers start on; reject switches them off. Global Privacy Control turns off marketing.
- INType: dpdpa
- Opt-in, with your DPO's contact details in the preferences panel.
- Everywhere elseType: generic
- Your default notice. Falls back here if a region is turned off.
| Name | Type | Description |
|---|---|---|
| EU, EEA, UK, CH | gdpr | Opt-in. Nothing non-essential runs until the visitor accepts. |
| US + CA | ccpa | Opt-out. Trackers start on; reject switches them off. Global Privacy Control turns off marketing. |
| IN | dpdpa | Opt-in, with your DPO's contact details in the preferences panel. |
| Everywhere else | generic | Your default notice. Falls back here if a region is turned off. |
Test locally with ?plain_country=IN or ?plain_country=US&plain_region=CA.
JavaScript API
window.PlainConsent is available as soon as the script runs. To call it before then, push a function onto an array; the queue runs when the script loads.
window.PlainConsent = window.PlainConsent || [];
PlainConsent.push((pc) => {
pc.on("ready", (state) => console.log(state.framework, state.categories));
});
// later, from your own settings page
PlainConsent.set({ analytics: true, marketing: false });
PlainConsent.open(); // show the preferences panel
PlainConsent.revoke(); // forget the choice and ask again- get()Type: ConsentState
- { framework, categories, decided, visitorId } for this visitor.
- acceptAll()Type: void
- Grant every category and log an accept_all receipt.
- rejectAll()Type: void
- Keep only essential and log a reject_all receipt.
- set(partial)Type: void
- Merge category changes and log a custom receipt.
- open()Type: void
- Show the preferences panel.
- revoke()Type: void
- Clear the stored choice, log a revoke receipt and show the banner again.
- on(event, fn)Type: () => void
- Listen for 'ready' or 'change'. Returns an unsubscribe function.
| Name | Type | Description |
|---|---|---|
| get() | ConsentState | { framework, categories, decided, visitorId } for this visitor. |
| acceptAll() | void | Grant every category and log an accept_all receipt. |
| rejectAll() | void | Keep only essential and log a reject_all receipt. |
| set(partial) | void | Merge category changes and log a custom receipt. |
| open() | void | Show the preferences panel. |
| revoke() | void | Clear the stored choice, log a revoke receipt and show the banner again. |
| on(event, fn) | () => void | Listen for 'ready' or 'change'. Returns an unsubscribe function. |
Every change also fires a plainconsent:change event on window with the state in event.detail, for code that can't hold a reference to the API.
Headless mode
Turn on headless mode in the Banner page and the script renders nothing. Blocking, storage, Google Consent Mode and receipts all keep working; you draw the interface and call the API.
PlainConsent.push((pc) => {
pc.on("ready", ({ decided }) => {
if (!decided) myBanner.show();
});
myBanner.onAccept = () => pc.acceptAll();
myBanner.onReject = () => pc.rejectAll();
});Google Consent Mode v2
With Consent Mode on, the script defines a gtag shim and sends a default signal immediately (with wait_for_update: 500), then an update whenever the choice changes. Load Google tags normally; they read the signals.
- ad_storage, ad_user_data, ad_personalizationType: marketing
- Granted when marketing is allowed.
- analytics_storageType: analytics
- Granted when analytics is allowed.
- functionality_storage, personalization_storageType: functional
- Granted when preferences are allowed.
- security_storageType: always
- Always granted.
| Name | Type | Description |
|---|---|---|
| ad_storage, ad_user_data, ad_personalization | marketing | Granted when marketing is allowed. |
| analytics_storage | analytics | Granted when analytics is allowed. |
| functionality_storage, personalization_storage | functional | Granted when preferences are allowed. |
| security_storage | always | Always granted. |
Tracker scanner
The Trackers page scans up to 10 pages of your site, starting from the homepage, and lists the third-party scripts they load, matched against known trackers and sorted into categories. Review the list and add what you want held. The scanner only fetches public http(s) addresses and refuses private and internal networks.
Consent analytics
The Overview page shows opt-in, opt-out and partial consent rates and banner bounce rate, by day, country, notice, device and browser. Impressions come from a view event when the banner shows; a bounce is sent when someone leaves without choosing. Every chart has a table view.
REST API
The script calls these endpoints for you. They allow cross-origin requests, and every error has the same shape: { "error": { "code", "message", "fields?" } }.
- GET /api/v1/config/:siteKeyType: 200 JSON
- The published config. Location in x-plain-country and x-plain-region.
- POST /api/v1/consentType: 201 JSON
- Record a decision. Returns { id, hash, seq } for the new receipt.
- POST /api/v1/eventType: 204
- Banner impression or bounce: { siteKey, kind: 'view' | 'bounce' }.
- GET /api/v1/receipts/:visitorIdType: 200 JSON
- A visitor's own history, ?siteKey= required. Choices and hashes only.
| Name | Type | Description |
|---|---|---|
| GET /api/v1/config/:siteKey | 200 JSON | The published config. Location in x-plain-country and x-plain-region. |
| POST /api/v1/consent | 201 JSON | Record a decision. Returns { id, hash, seq } for the new receipt. |
| POST /api/v1/event | 204 | Banner impression or bounce: { siteKey, kind: 'view' | 'bounce' }. |
| GET /api/v1/receipts/:visitorId | 200 JSON | A visitor's own history, ?siteKey= required. Choices and hashes only. |
{
"siteKey": "pk_live_YOUR_SITE_KEY",
"visitorId": "3f9c0a1e7b2d4c6e8f00112233445566",
"action": "custom",
"framework": "dpdpa",
"categories": { "essential": true, "functional": true, "analytics": false, "marketing": false },
"configVersion": 4
}Consent requests must come from your site's domain or a subdomain of it, and are rate limited per anonymised address. The server adds the time, country, device, browser and a truncated, salted hash of the IP address before writing the receipt.