PayloadSolutions

Cookies & scripts

Declaring trackers so the banner, the loader and the cookie policy all agree, plus the built-in presets and Google Consent Mode.

One document per third-party script, pixel, embed, SDK or cookie. This collection is the source of truth: the banner shows the categories that have trackers in them, the loader injects the ones whose category is granted, and the cookie policy's table is generated from the same rows.

Fields

FieldNotes
Name, VendorShown in the preferences dialog and the cookie table.
CategoryExactly one. Determines when this tracker is allowed to run.
KindHow it is enforced — see below.
PurposeOne or two sentences a non-technical visitor can follow. This is the text a regulator reads.
Vendor privacy URLLink to the vendor's policy. You generally have to name the recipient of the data; the dashboard warns when this is empty.
Cookies / storage itemsDisclosure rows: name, domain, storage type (cookie, localStorage, sessionStorage, indexedDB), duration and description.
LoaderOnly for script and pixel. How the plugin loads it.
EnabledOff means it is not in the config at all: not loaded, not listed, not counted in the version hash.
Environmentsdevelopment, production, or both. Trackers are filtered by NODE_ENV at config time, so a staging-only pixel never reaches production visitors.

Kinds

The kind decides who enforces the gate.

KindEnforced byUse for
scriptthe plugin's loader — injects <script> when the category is grantedGA4, Plausible, Hotjar, most vendor snippets
pixelthe same loaderconversion pixels that arrive as a script tag
iframeyou, with <ConsentGate> in the pageYouTube, Vimeo, Google Maps, any embed
sdkyou, by asking the store before you initialisePostHog, Intercom, anything you import and call
cookie-onlynothing to load — disclosure onlyStripe's fraud cookies, your own first-party cookies

iframe and sdk are not a weakness; they are the honest answer. The plugin cannot stop an <iframe> you rendered or an npm package you imported, so it declares them, gates them in the UI, and gives you has() to gate them in code.

<ConsentGate category="functional" fallback={<EmbedPlaceholder />}>
  <iframe src="https://www.youtube-nocookie.com/embed/…" />
</ConsentGate>
const { has } = useConsent()
useEffect(() => {
  if (has('analytics')) posthog.opt_in_capturing()
  else posthog.opt_out_capturing()
}, [has])

The loader

For script and pixel kinds:

FieldNotes
SrcScript URL. Leave empty when using inline code.
Inline codeA snippet, executed on your site. This is a trust boundary — see the warning below.
StrategyAs soon as allowed, or When the browser is idle (requestIdleCallback).
Consent Mode managedGoogle tags only. Load immediately and let Consent Mode gate the data, instead of withholding the script.
AttributesExtra script attributes as JSON, e.g. {"data-domain":"example.com"}.

Injected scripts carry data-consent-tracker="<tracker id>", which makes them easy to assert on in tests and to spot in the network panel. The loader is idempotent: a tracker is injected at most once per page.

Scripts cannot be unloaded. When a visitor revokes a category whose scripts already ran, the store sets needsReload, and the banner component surfaces the "reload to apply" notice. This is a browser limitation, not a shortcut — no consent tool can un-run a third-party script.

Inline code runs on every page of your site with full access to the DOM and to your users' sessions. Anyone who can edit this collection can therefore ship JavaScript to production. Lock the collection down with access.manage if your editors are not your developers.

Google's tags are a special case. Withholding gtag.js until consent means losing conversion modelling and, in Google's newer terms, running afoul of their own requirements. Consent Mode is the supported alternative: the tag loads immediately, but every storage decision is denied until you say otherwise.

Mark a tracker Consent Mode managed and the plugin will:

  1. emit gtag('consent', 'default', …) in <head> from <ConsentModeScript>, computed from the cookie that is already there, with wait_for_update;
  2. load the script regardless of the decision;
  3. emit gtag('consent', 'update', …) whenever the visitor changes anything.

Which signals a category grants is set per category. The seeded mapping covers the four Consent Mode v2 signals plus the three older ones. ads_data_redaction and url_passthrough come from Consent settings.

The default script is idempotent — it guards on window.__plConsentModeDefault — so a double render in development cannot push the defaults twice.

Presets

seed.trackers accepts a preset key, or { key, vars, enabled }. Each preset arrives with its real cookie names, durations, purposes and vendor privacy links already filled in; {{placeholders}} in URLs and cookie names are substituted from vars, and anything you leave out is logged as a warning and left in the document for an editor to fix.

KeyNameCategoryKindNeeds
posthogPostHoganalyticssdkprojectKey
posthog-euPostHog (EU Cloud)analyticssdkprojectKey
ga4Google Analytics 4analyticsscript (Consent Mode)measurementId
gtmGoogle Tag Managermarketingscript (Consent Mode)containerId
meta-pixelMeta PixelmarketingscriptpixelId
linkedin-insightLinkedIn Insight TagmarketingscriptpartnerId
hotjarHotjaranalyticsscriptsiteId
clarityMicrosoft ClarityanalyticsscriptprojectId
intercomIntercomfunctionalsdkappId
crispCrispfunctionalscriptwebsiteId
youtubeYouTube embedsfunctionaliframe
vimeoVimeo embedsfunctionaliframe
google-mapsGoogle Maps embedsfunctionaliframe
stripeStripenecessarycookie-only
vercel-analyticsVercel Web Analyticsanalyticssdk
umamiUmamianalyticsscriptscriptUrl, websiteId
plausiblePlausibleanalyticsscriptdomain
consentPlugin({
  seed: {
    company: { /* … */ },
    trackers: [
      { key: 'ga4', vars: { measurementId: 'G-XXXXXXX' } },
      { key: 'posthog-eu', vars: { projectKey: 'phc_…' } },
      { key: 'meta-pixel', vars: { pixelId: '123…' }, enabled: false },
      'youtube',
      'stripe',
    ],
  },
})

Presets are only used when the trackers collection is empty. To add one later, either create the document by hand in the admin or call seedTrackers from a script:

import { seedTrackers, getPluginOptions } from '@payload-solutions/plugin-consent/server'

await seedTrackers(payload, getPluginOptions(payload), [{ key: 'crisp', vars: { websiteId: '…' } }])

The raw definitions are exported as TRACKER_PRESETS if you want to build your own seeding flow on top of them.

Adding your own

Nothing about the presets is privileged — a hand-made tracker document behaves identically. What matters for a self-hosted or in-house tool:

  • Pick the honest category. A "privacy-friendly" analytics tool that sets no cookies still processes personal data; whether it needs consent depends on your jurisdiction and your DPA, not on its marketing page. If it genuinely needs no consent, put it in necessary and say why in the purpose field.
  • List the storage, not just the cookies. localStorage and indexedDB are covered by the same ePrivacy rule as cookies. The storage field exists for exactly this.
  • Use cookie-only for your own cookies. Session, CSRF and consent cookies belong in the cookie policy too, and the seeded cookie policy's table will list them.

Trackers are not the processor register

A tracker is a browser concern: what runs on the page and what the visitor may refuse. Your host, database, email sender and error tracker never touch the banner, so they are not here — they belong in Processors, which is what the privacy policy's recipients table and your sub-processor list are built from.

Vendors that are both — PostHog, Intercom, GA4 — get a row in each, linked by the tracker relationship on the processor. The dashboard warns when a declared tracker has no matching processor row, which is how the two lists drift apart in practice.

Extra fields

Multi-tenant setups usually need a relationship on the tracker:

consentPlugin({
  trackerFields: [{ name: 'tenant', type: 'relationship', relationTo: 'organizations', index: true }],
})

The fields are appended to the collection as-is. They do not reach the client config — filter on them in your own code, or scope the collection with access.manage.

On this page