Shopify Customer Privacy API: Build a Custom Banner

The Shopify Customer Privacy API is a browser JavaScript API, loaded with window.Shopify.loadFeatures, that records a visitor’s cookie choices with setTrackingConsent and reports what is allowed through methods such as analyticsProcessingAllowed(). Shopify applies those choices to its own pixels and checkout. Any script you hard-coded into the theme still needs you to gate it.

So a custom banner is mostly a script-gating job. Shopify’s own cookie banner, and the consent it collects, govern what the Customer Privacy API reference calls “Shopify-managed surfaces”: pixels, Shopify Audiences and checkout. A Google Tag Manager or Hotjar <script> pasted into layout/theme.liquid is not one of those. It runs on page load whatever the visitor clicked.

The rest of this post builds a banner in a Liquid theme that records consent through the API and only loads a third-party tag once analytics is allowed. Everything is written against the API as documented in November 2026, loading version 0.2.

When do you need a custom banner at all?

Start in the admin, under Settings > Customer Privacy. Per Shopify’s privacy settings guide, new stores get automated settings that switch the Shopify banner on for visitors in the UK and EEA when you have active markets there. If the Shopify banner and your pixels cover everything that sets cookies, stop here. You do not need code.

You need your own banner when the design or copy has to match a brand the stock banner cannot, or when the theme loads tags directly. In that case turn off Use automated settings in the Cookie banner section, remove the Shopify banner, and set the regions by hand. Keep those regions identical to the ones your own banner targets. Shopify’s guide is blunt about the failure: if the regions don’t match, “non-essential data can be processed before a visitor provides consent.”

How does the Shopify Customer Privacy API load in a theme?

You do not add a script tag for it. On an Online Store page Shopify injects its storefront scripts, and one of them, load_feature-<hash>.js, defines window.Shopify.loadFeatures. On a live store we inspected in November 2026 that file is loaded with defer. So an inline script in <head> that calls loadFeatures straight away can run before the function exists.

Wait for DOMContentLoaded. Deferred scripts have run by then, so the loader is there. Then ask for the feature:

window.Shopify.loadFeatures(
  [{ name: 'consent-tracking-api', version: '0.2' }],
  (error) => {
    if (error) return;
    // window.Shopify.customerPrivacy is ready here
  },
);

If the callback gets no error, the API sits at window.Shopify.customerPrivacy.

Which method tells you what you may load?

There are two families, and they answer different questions.

currentVisitorConsent() returns what the visitor clicked: an object with marketing, analytics, preferences and sale_of_data, each 'yes', 'no' or '' for no decision yet. It ignores where the visitor is and how the merchant configured the store.

The Allowed methods (analyticsProcessingAllowed(), marketingAllowed(), preferencesProcessingAllowed(), saleOfDataAllowed()) return a boolean that combines all three: merchant settings, the visitor’s location and their recorded choice. A visitor in a region with no consent requirement gets true without ever seeing a banner, because the docs say the default there “is to allow all processing purposes”. Gate scripts on the Allowed methods. Use currentVisitorConsent() only to decide whether someone has answered yet.

shouldShowBanner() is meant to answer “should I ask?”, and the reference is not consistent about it. One line says it returns true “if consent isn’t already set and the visitor is in a region showing consent”. The note directly above that says the method “doesn’t indicate if consent has already been given.” Until Shopify settles it, check both: show the banner when shouldShowBanner() is true and the visitor has not answered.

Save this as snippets/consent-banner.liquid and render it just before </body> in layout/theme.liquid with {% render 'consent-banner' %}. Swap the example tag URL for the script you are gating, and delete that script’s original tag from the theme.

<div id="consent-banner" class="consent-banner" hidden>
  <p>We use cookies for analytics and marketing. You can change your choice from the Cookie preferences link in the footer.</p>
  <button type="button" data-consent="accept">Accept all</button>
  <button type="button" data-consent="reject">Reject non-essential</button>
</div>

<script>
  document.addEventListener('DOMContentLoaded', () => {
    const banner = document.getElementById('consent-banner');
    if (!banner || !window.Shopify || !window.Shopify.loadFeatures) return;

    function loadAnalytics() {
      if (document.getElementById('third-party-analytics')) return;
      const tag = document.createElement('script');
      tag.id = 'third-party-analytics';
      tag.src = 'https://analytics.example.com/tag.js';
      tag.async = true;
      document.head.appendChild(tag);
    }

    window.Shopify.loadFeatures(
      [{ name: 'consent-tracking-api', version: '0.2' }],
      (error) => {
        if (error) {
          console.error('Customer Privacy API failed to load', error);
          return;
        }
        const privacy = window.Shopify.customerPrivacy;

        if (privacy.analyticsProcessingAllowed()) loadAnalytics();

        const consent = privacy.currentVisitorConsent();
        const undecided = consent.analytics === '' || consent.marketing === '';
        if (privacy.shouldShowBanner() && undecided) banner.hidden = false;

        banner.addEventListener('click', (event) => {
          const button = event.target.closest('[data-consent]');
          if (!button) return;
          const granted = button.dataset.consent === 'accept';
          privacy.setTrackingConsent(
            { analytics: granted, marketing: granted, preferences: granted },
            () => {
              banner.hidden = true;
            },
          );
        });

        document.querySelectorAll('[data-open-consent]').forEach((link) => {
          link.addEventListener('click', () => {
            banner.hidden = false;
          });
        });
      },
    );

    document.addEventListener('visitorConsentCollected', (event) => {
      if (event.detail.analyticsAllowed) loadAnalytics();
    });
  });
</script>

The footer link is a real button, so it works from the keyboard without extra code:

<button type="button" data-open-consent>Cookie preferences</button>

A few details in there are deliberate.

The analyticsProcessingAllowed() check runs on every page load. visitorConsentCollected fires only “when consent changes, not when the listener is added”, so a returning visitor who accepted last week never triggers it. Without the load-time check their analytics would stop after the first page.

The event’s detail uses different names from the methods: analyticsAllowed and preferencesAllowed, next to marketingAllowed and saleOfDataAllowed. Typing analyticsProcessingAllowed into the event handler gives you undefined, which is falsy, so the tag silently never loads.

setTrackingConsent runs only from a click. The reference says consent must be recorded “on a visitor interaction” and “never done automatically on behalf of the visitor”. A banner that calls it on a timer or on scroll breaks that rule.

The if (document.getElementById(...)) guard stops the tag loading twice when the load-time check and the event both fire in one session.

What the snippet cannot undo

If a visitor accepts, then reopens the banner and rejects, setTrackingConsent records the new choice, but the tag already on the page keeps running until the visitor moves to another page. Removing a <script> element does not stop code that has already executed. For most stores, stopping on the next page load is good enough. If a vendor offers an opt-out call, run it from the reject path.

Do not read or write Shopify’s consent cookies yourself. The reference warns that doing so will fail “when new versions are released”. The API is the only supported way in, which also means you should not migrate an old banner by copying its cookie values across.

Data sale opt-out is a separate flow. The sale_of_data key works through the same setTrackingConsent call, but Shopify wants it started by the customer from a “Do not sell or share” page, not offered in a load-time banner. In regions configured for opt-out, the Global Privacy Control signal is honoured automatically and setTrackingConsent cannot override it. Server-side, there is the Admin API dataSaleOptOut mutation.

Does it work on Hydrogen or a headless storefront?

Not as written. A headless storefront has no content_for_header, so there is no loadFeatures injected for you. The API reference lists extra setTrackingConsent parameters for custom storefronts (headlessStorefront: true, checkoutRootDomain, storefrontRootDomain, storefrontAccessToken), but then points custom storefronts at Hydrogen’s own analytics and consent, calling it “the only way to fully integrate the cookie banner with Shopify Analytics”.

On classic Hydrogen (2026.4 and earlier), that means a consent object on Analytics.Provider with checkoutDomain, storefrontAccessToken and withPrivacyBanner: true, plus a checkout subdomain set up as described in the legacy Hydrogen consent guide. That guide also notes the banner does not work on default *.myshopify.dev Oxygen URLs, so test locally or on an assigned domain, not a preview URL. If you build carts through the Storefront API, consent travels to checkout through @inContext(visitorConsent:), which our post on @inContext and local prices covers.

Pixels are the other half. Custom and app pixels already wait for consent before they run, so a tag that can live in a pixel is better moved there than gated by hand. Our comparison of app pixels and custom pixels explains the trade-off.

What we would do

If the theme has no hard-coded tags, use Shopify’s banner and spend the time elsewhere. If it does, move what you can into a custom pixel first, then build a banner like the one above for whatever has to stay in the theme. Gate on the Allowed methods, check on every page load, and keep the admin regions and your banner’s regions the same. Give “Reject” the same size and placement as “Accept”.

We build and audit Shopify themes, including moving hard-coded tags behind consent. Our Shopify development work covers themes, Hydrogen storefronts and migrations onto Shopify.

Need this built properly?

Get in touch