Consent Management for A/B Testing

From @pack/hydrogen 4.0, Pack records each visitor's consent and honors it. Stores using Shopify's privacy banner get this without writing any code. Stores using another consent tool connect it with a few lines. Storefronts on earlier versions manage consent themselves; see Before @pack/hydrogen 4.0.

Pack records an answer for four purposes: analytics, marketing, preferences and sale of data. Each is granted, denied, or not yet answered. Today Pack acts on analytics:

  • The Pack visitor id. With analytics consent, Pack keeps one id for the visitor across visits. Without it, Pack uses a new id on every request. This is the id sent with Pack's analytics and each CMS request, and returned by useAbTestSessionId().
  • What it does not cover. Pack's analytics still records page views without consent. Each carries the visitor's IP address and an id derived from their browser, IP address and your hostname that changes daily, so page views from the same browser can be linked within a day.
  • Exposure events. PackTestProvider only reports test exposures, and only records them in the __pack_exposed cookie, with analytics consent. If you do not pass hasUserConsent, it follows Pack's answer, which it reads from your root loader's ...pack.getPackContextData(). Without that in the root loader, no exposures are reported.
  • The Cookie audience condition. With require consent on, a visitor without analytics consent does not match. That includes a visitor who has not answered when unknownConsent is 'denied' (below).

Showing tests and personalizations does not depend on consent. The cookies that do this (__pack_test and __pack_s) are set and read by your own storefront and are not sent to Pack. The tests and variants a visitor is in are sent with each CMS request, so Pack returns the right content.

Visitors who have not answered

By default Pack treats a visitor who has not answered a banner as consenting, which is how storefronts behaved before 4.0. Every visitor's first page view is unanswered, as is anyone who ignores the banner.

To change that, pass unknownConsent to PackSession.init in server.ts:

server.ts

PackSession.init(request, [env.SESSION_SECRET], {
  unknownConsent: 'denied',
}),

It also takes a function, for example to decide by the visitor's country. It receives the request and returns 'granted' or 'denied'. If it throws, Pack treats the visitor as not consenting.

Shopify's privacy banner

If your storefront uses Shopify's privacy banner and wraps the app in PackProvider from @pack/react, there is nothing to add.

PackProvider reads the visitor's answer from Shopify's Customer Privacy API when the page loads and whenever they answer the banner, and sends it to Pack. Pack stores it in the __pack_user_consent cookie, set by your storefront's server so that Safari does not expire it after 7 days. Only purposes the visitor has answered, and that changed, are sent.

For OneTrust, Cookiebot and similar tools, call setPackConsent() from @pack/react with the visitor's answer, both when the page loads and whenever they change it. For OneTrust:

app/root.tsx or a client component

import {useEffect} from 'react';
import {setPackConsent} from '@pack/react';

declare global {
  interface Window {
    OneTrust?: {IsAlertBoxClosed?: () => boolean};
    OnetrustActiveGroups?: string;
  }
}

useEffect(() => {
  const sync = () => {
    // Before the visitor answers, OneTrust's groups are only defaults.
    if (!window.OneTrust?.IsAlertBoxClosed?.()) return;
    const groups = window.OnetrustActiveGroups || ''; // e.g. ",C0001,C0002,"
    setPackConsent({
      analytics: groups.includes(',C0002,'),
      preferences: groups.includes(',C0003,'),
      marketing: groups.includes(',C0004,'),
    });
  };

  sync(); // OneTrust may have loaded before this ran
  // Fires when OneTrust loads and whenever the visitor changes their answer.
  window.addEventListener('OneTrustGroupsUpdated', sync);
  return () => window.removeEventListener('OneTrustGroupsUpdated', sync);
}, []);
  • Sending the answer on every page load, not only when it changes, means an answer given before your storefront upgraded to @pack/hydrogen 4.0 still reaches Pack.
  • Answers that already match what Pack has recorded are not sent again, so the load-time call costs nothing once Pack has the answer.
  • Purposes you leave out keep their current answer.
  • OneTrust group ids are set per OneTrust account. C0002, C0003 and C0004 are OneTrust's defaults; map the groups your account uses.

Optional: read the tool's cookie on the server

setPackConsent() is enough on its own. To also apply the tool's answer on the very first request after the visitor answers, pass getConsent to PackSession.init. When it returns an answer for a purpose, that answer wins over Pack's cookie.

server.ts

PackSession.init(request, [env.SESSION_SECRET], {
  getConsent: (req) => {
    const raw = /(?:^|;\s*)OptanonConsent=([^;]*)/.exec(
      req.headers.get('Cookie') || '',
    )?.[1];
    if (!raw) return undefined;

    const params = new URLSearchParams(raw);
    // OneTrust writes default groups before the visitor answers.
    if (Number(params.get('interactionCount') || 0) < 1) return undefined;

    const groups = Object.fromEntries(
      (params.get('groups') || '').split(',').map((group) => group.split(':')),
    );
    const on = (id: string) =>
      groups[id] === undefined ? undefined : groups[id] === '1';

    return {
      analytics: on('C0002'),
      preferences: on('C0003'),
      marketing: on('C0004'),
    };
  },
}),

getConsent runs on every request before the page renders, with no timeout. Read the tool's cookie from the request, as above, rather than calling an API. Return undefined for a purpose the tool has no answer for.

usePackCookies()

usePackCookies() from @pack/hydrogen still works, but is deprecated. From 4.0 it only records analytics consent, and only when you pass hasUserConsent and it differs from what Pack has recorded. Use setPackConsent() instead.

The root loader data from getPackContextData() includes:

  • packConsent — each purpose as granted, denied or unknown
  • packConsentAllowed — whether each purpose may be used, after the unanswered-visitor policy

On the server, the same answers are on context.pack.consent and context.pack.consentAllowed.

Pack's cookies

CookieSet byLastsHoldsDepends on consent
__packYour storefront's server (signed)360 daysPreview settings, and the visitor id with analytics consentThe visitor id: analytics
__pack_testYour storefront's server (signed)360 daysThe tests and variants the visitor is in, and a random id used to assign themNo
__pack_sYour storefront's server30 min, rollingWhether this visit started as a new or returning visitorNo
__pack_exposedThe browser (see below)24 hoursExposures already reported, so each is reported onceWritten only when exposures may be reported (above)
__pack_user_consentYour storefront's server360 daysThe visitor's consent answersNo — it records consent

None of these are HttpOnly: scripts on the page can read them.

The random id in __pack_test lasts as long as the cookie and does not depend on consent. Your storefront's server also writes __pack_exposed once, without checking consent, when it converts the exposedTest cookie from an earlier version.

  • In GTM, require consent for:
    • ad_storage
    • analytics_storage
    • personalization_storage
  • When consent is denied, block all GA4 tags and A/B exposure events.

Preview vs. production

  • Shopify's consent banner may only appear in production.
  • For OneTrust QA in staging:
    • Set PUBLIC_ONETRUST_DATA_DOMAIN_SCRIPT in your .env.
    • Use a permanent staging URL and test scripts to validate flows.

Expected behaviors

  • Consent declined (or not yet answered, when unknownConsent is 'denied'; by default an unanswered visitor is treated as consented)
    • No exposure events are reported.
    • No view_experiment events in GTM or GA4.
    • The Pack visitor id changes on every request.
    • Pack's analytics still records page views, with an id that changes daily.
  • Consent granted
    • view_experiment appears in GTM Debug Mode and GA4 DebugView.
    • Experiment exposure data pushes to window.dataLayer.
  • Consent withdrawn mid-session
    • view_experiment events stop.
    • The stored Pack visitor id is removed on the same response.
  • BigQuery
    • If consent was granted, the event appears in BigQuery within 24 hours.
    • If denied, no experiment data is stored.

Before @pack/hydrogen 4.0

Earlier versions record only an analytics answer, written by usePackCookies, and do not pass it to PackTestProvider. Your theme passes consent to PackTestProvider itself, which holds exposures until hasUserConsent is true:

useEffect(() => {
  function onConsentUpdate(evt: any) {
    const hasFunctional = evt.detail.groups.includes('C0003');
    setHasUserConsent(hasFunctional);
  }
  window.addEventListener('OneTrustGroupsUpdated', onConsentUpdate);
  return () => window.removeEventListener('OneTrustGroupsUpdated', onConsentUpdate);
}, []);
  • Listen to OneTrustGroupsUpdated and visitorConsentCollected.
  • If you use a different consent provider, update the event names accordingly.

This keeps working on 4.0: a hasUserConsent you pass always wins over Pack's answer for exposures.

Was this page helpful?