Cookie consent under PECR
Ask for, record and honour a shopper's cookie choices on a headless storefront, with the banner and cookie table served by the API.
Under UK PECR, storing or reading information on a visitor's device generally needs their consent unless it is strictly necessary for the service they asked for, with a few narrow exceptions. Elara One gives a headless storefront what it needs to do this properly: a merchant-published banner, a server-side consent record for every choice, and a cookie table generated from the platform's storage register. Your storefront renders the banner and keeps three first-party cookies.
This page explains how the consent API works. It is not legal advice; check your own obligations with an adviser.
How it works
- Your server keeps the shopper's identifiers in first-party, httpOnly cookies on your own domain.
- On each page, your server asks
GET /v1/consentwhat the shopper has agreed to and whether to show the banner. - When the shopper chooses, your server sends the choice to
PUT /v1/consent, which writes a consent record and returns a token for theconsentcookie. - Nothing non-essential runs until the answer allows it. Elara One enforces the same choice on its own servers.
Both consent routes are server-only: they need X-Tenant-Secret, so a browser can never call them. A call without the secret gets 403 TENANT_SECRET_INVALID.
The cookies
The starter storefronts use these three cookies. All are set by your server, httpOnly, SameSite=Lax and Secure, so browser JavaScript cannot read them.
| Cookie | Holds | Lifetime | Category |
|---|---|---|---|
sid | A random id for the visit | 30 minutes, renewed on each request | Strictly necessary |
vid | A random id for the visitor | 13 months from when it is set, never extended | Analytics: only set after consent |
consent | The token PUT /v1/consent returned | 12 months, or until the choice expires if sooner | Strictly necessary |
Generate sid and vid yourself as random opaque ids: 8 to 128 characters of letters, digits, ., _, : and -. Your server forwards the values as headers:
| Header | From |
|---|---|
X-Session-Id | the sid cookie (required on PUT) |
X-Visitor-Id | the vid cookie, when there is one |
X-Consent-Token | the consent cookie, when there is one |
The engine stores only one-way hashes of these values. For a signed-in shopper, also send their bearer token: their choice then follows them to their other devices.
Ask what the shopper has agreed to
curl https://api.elaraone.app/api/v1/consent \
-H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
-H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
-H "X-Session-Id: s_3f9a0c1b2d4e5f60718293a4b5c6d7e8" \
-H "X-Consent-Token: $CONSENT_COOKIE"{
"data": {
"record_id": null,
"show_banner": true,
"reprompt_reason": "no_choice",
"choice": null,
"categories": {
"strictly_necessary": true,
"analytics": false,
"advertising": false,
"personalisation": false
},
"consent_mode": {
"ad_storage": "denied",
"ad_user_data": "denied",
"ad_personalization": "denied",
"analytics_storage": "denied",
"personalization_storage": "denied",
"functionality_storage": "granted",
"security_storage": "granted"
},
"recorded_at": null,
"expires_at": null,
"consent_token": null,
"delete_visitor_id": true,
"withdrawn": [],
"banner": {
"version": 3,
"privacy_notice_version": "2026-10",
"title": "Cookies at Hearth & Oak",
"body": "We use cookies to run the shop. With your permission we also use them to understand how the shop is used and to measure our adverts.",
"labels": {
"accept_all": "Accept all",
"reject_all": "Reject all",
"manage": "Choose",
"save": "Save choices",
"settings_link": "Cookie settings"
},
"links": { "privacy_notice": "/privacy", "cookie_table": "/cookies" },
"strictly_necessary": { "label": "Strictly necessary", "description": "Your basket, checkout and sign-in. Always on." },
"categories": [
{ "key": "analytics", "label": "Analytics", "description": "How the shop is used, so we can improve it." },
{ "key": "advertising", "label": "Advertising", "description": "Measuring which adverts bring visitors." }
]
}
}
}show_bannersays whether to show the banner.reprompt_reasonsays why:no_choice,expired(after 12 months),register_changedorpurposes_changed.categoriessays what may run now.strictly_necessaryis alwaystrue.consent_modegives Google Consent Mode v2 signals, worked out from the choice, if you use Google's tags.delete_visitor_idistruewhenever analytics is not granted. When it istrueand avidcookie exists, expire it now.banneris always the current published banner.
Render the banner
Render the banner from banner, in your own design, following these rules:
- Reject is as easy as accept. Put
labels.reject_allnext tolabels.accept_all, in the same style and size, one click away. - Granular.
labels.manageopens a view with one switch per entry inbanner.categories, each starting off. Showstrictly_necessaryas always on, never as a switch. - No pre-ticked boxes and no wording that pressures the shopper.
- Nothing non-essential before the choice. Do not set
vid, load third-party tags or send analytics untilcategoriesallows it. The shop itself must work while the banner is open. - A permanent way back. Keep a
labels.settings_linklink on every page, usually in the footer, that reopens the choices. - Linked to the notice. Link
links.privacy_noticeandlinks.cookie_table.
The merchant writes the wording in the store admin under Settings → Privacy → Consent banner (or Analytics → Privacy & attribution → Consent banner on stores with Analytics). Each publish is a new, unchangeable banner version, and the admin refuses a banner that hides "Reject all" or pre-ticks a category. Integrations can publish through PUT /v1/merchant/consent/banner with a merchant key that has the manage_consent ability; the same rules apply.
Record the choice
When the shopper presses a button, your server sends every category explicitly, where the choice was made, and the banner version they saw.
curl -X PUT https://api.elaraone.app/api/v1/consent \
-H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
-H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
-H "X-Session-Id: s_3f9a0c1b2d4e5f60718293a4b5c6d7e8" \
-H "Idempotency-Key: 0f9e8d7c-6b5a-4c3d-9e2f-1a0b9c8d7e6f" \
-H "Content-Type: application/json" \
-d '{
"categories": { "analytics": false, "advertising": false, "personalisation": false },
"source": "banner",
"banner_version": 3
}'| Field | Notes |
|---|---|
categories.analytics, categories.advertising, categories.personalisation | All three, as booleans. A category the banner did not offer must be false. |
source | banner, settings_link or account_page. |
banner_version | The banner.version the shopper saw. Defaults to the current one. |
The answer is 201 with the same shape as GET /v1/consent, plus consent_token. Store it in the consent cookie and forward it from then on. A malformed id header or a missing X-Session-Id is a 422 VALIDATION_FAILED.
To link a new visitor id, mint vid before the PUT that grants analytics, send it as X-Visitor-Id, and set the cookie only after the answer confirms categories.analytics is true.
Withdrawing is the same call with the category set to false. The answer's withdrawn lists what was taken back, and delete_visitor_id tells you to expire vid. Every choice writes a new record, and the old one is kept as proof of what was allowed before.
The cookie table
GET /v1/privacy/cookies returns the storage register as data for your cookie table page, the page links.cookie_table points at (/cookies by default). It needs only X-Tenant-Key, carries an ETag, and may be cached for 5 minutes.
{
"data": {
"register_version": "1.23",
"status": "proposed",
"updated": "2026-10-23",
"categories": [
{ "key": "strictly_necessary", "label": "Strictly necessary", "consent_required": false, "summary": "…" }
],
"identifiers": [
{
"key": "sid",
"source": "platform",
"name": "Session",
"mechanism": "First-party cookie: HttpOnly, Secure, SameSite=Lax",
"cookie": { "name": "sid", "first_party": true, "http_only": true, "secure": true, "same_site": "Lax", "max_age_months": null, "idle_timeout_minutes": 30 },
"set_by": "…",
"lifetime": "…",
"purpose": "…",
"category": "strictly_necessary",
"category_note": null,
"stored_server_side": "…",
"deleted_when": "…"
}
],
"click_ids": ["…"],
"never": ["…"],
"changelog": ["…"]
}
}Render one row per entry in identifiers. cookie is null for identifiers kept only on the server. Because the table comes from the register, it always matches what the platform actually stores.
Google Analytics in the starters
The starter storefronts include an optional Google Analytics 4 tag. It stays off unless the store sets a measurement id, and even then Google's script is loaded only once the shopper has granted both analytics and advertising. Consent Mode is set to "denied" before the tag runs and updated from consent_mode on every choice. If the shopper later withdraws either category, the tag is switched off for the rest of the page and not loaded on the next one.
Elara One's own first-party analytics and ad conversion features are coming soon. The consent record you write today is what they will check.
Flows
Automation flows from a developer's point of view, including triggers, actions, the webhook action, loop protection, consent and the merchant API.
SEO for headless storefronts
Server rendering, slugs, CMS page metadata, redirects, canonical URLs, structured data, sitemaps and not-found handling with the Elara One API.