Skip to content
Elara One docs

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

  1. Your server keeps the shopper's identifiers in first-party, httpOnly cookies on your own domain.
  2. On each page, your server asks GET /v1/consent what the shopper has agreed to and whether to show the banner.
  3. When the shopper chooses, your server sends the choice to PUT /v1/consent, which writes a consent record and returns a token for the consent cookie.
  4. 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.

CookieHoldsLifetimeCategory
sidA random id for the visit30 minutes, renewed on each requestStrictly necessary
vidA random id for the visitor13 months from when it is set, never extendedAnalytics: only set after consent
consentThe token PUT /v1/consent returned12 months, or until the choice expires if soonerStrictly 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:

HeaderFrom
X-Session-Idthe sid cookie (required on PUT)
X-Visitor-Idthe vid cookie, when there is one
X-Consent-Tokenthe 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"
Response (no choice yet, trimmed)
{
  "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_banner says whether to show the banner. reprompt_reason says why: no_choice, expired (after 12 months), register_changed or purposes_changed.
  • categories says what may run now. strictly_necessary is always true.
  • consent_mode gives Google Consent Mode v2 signals, worked out from the choice, if you use Google's tags.
  • delete_visitor_id is true whenever analytics is not granted. When it is true and a vid cookie exists, expire it now.
  • banner is 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_all next to labels.accept_all, in the same style and size, one click away.
  • Granular. labels.manage opens a view with one switch per entry in banner.categories, each starting off. Show strictly_necessary as 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 until categories allows it. The shop itself must work while the banner is open.
  • A permanent way back. Keep a labels.settings_link link on every page, usually in the footer, that reopens the choices.
  • Linked to the notice. Link links.privacy_notice and links.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
  }'
FieldNotes
categories.analytics, categories.advertising, categories.personalisationAll three, as booleans. A category the banner did not offer must be false.
sourcebanner, settings_link or account_page.
banner_versionThe 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.

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.

Response (trimmed)
{
  "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.