# Cookie consent under PECR

URL: /docs/guides/cookie-consent

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 [#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-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 [#ask-what-the-shopper-has-agreed-to]

```bash tab="cURL"
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"
```

```ts tab="TypeScript"
const jar = await cookies()
const res = await fetch(`${ENGINE}/consent`, {
  headers: {
    ...engineHeaders(),
    ...(jar.get('sid') ? { 'X-Session-Id': jar.get('sid')!.value } : {}),
    ...(jar.get('vid') ? { 'X-Visitor-Id': jar.get('vid')!.value } : {}),
    ...(jar.get('consent') ? { 'X-Consent-Token': jar.get('consent')!.value } : {}),
  },
  cache: 'no-store',
})
const { data: consent } = await res.json()
```

```json title="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]

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 [#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.

```bash tab="cURL"
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
  }'
```

```ts tab="TypeScript"
const res = await fetch(`${ENGINE}/consent`, {
  method: 'PUT',
  // subjectHeaders: X-Session-Id, X-Visitor-Id and X-Consent-Token, built as in the GET example
  headers: { ...engineHeaders(), ...subjectHeaders, 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' },
  body: JSON.stringify({
    categories: { analytics: true, advertising: false, personalisation: false },
    source: 'banner', // or 'settings_link', 'account_page'
    banner_version: banner.version,
  }),
})
const { data } = await res.json()

jar.set('consent', data.consent_token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', expires: new Date(data.expires_at) })
if (data.delete_visitor_id) jar.delete('vid')
```

| 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 [#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.

```json title="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 [#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.
