Theme App Extensions and Web Pixels

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Shopify CLI 5.0.0, the GraphQL Admin API version 2026-10 (the latest stable version, released on 2026-10-01), @shopify/web-pixels-extension 2.18.0 and the Customer Privacy API feature consent-tracking-api version 0.2, and checked on 2026-10-27. The Web Pixels API, the Customer Privacy API and Liquid are not versioned with the Admin API: Shopify lists all three among the surfaces that can change at any time, so the dates in the changelog entries quoted below matter more than a version number. None of the pages that describe theme app extensions or web pixels lists a Shopify plan requirement, so both are available to every app on every plan. Theme app extensions render only in Online Store themes; a headless storefront does not render theme blocks and has its own consent path, described at the end of the consent section.

An app that wants something to happen on the storefront has two jobs. It may need to show something, such as a rating next to the price or a floating chat bubble, and it may need to observe something, such as which variants customers add to the cart. For about a decade apps did both by editing the merchant’s theme through the Asset API or by registering a remote JavaScript file as a script tag. Those techniques left "ghost code" behind when the app was uninstalled, broke when the theme changed, and gave the merchant no way to see or switch off what the app was doing. Two extension types replace them. A theme app extension ships Liquid blocks, CSS and JavaScript that merchants add or activate in the theme editor. A web pixel extension ships a small JavaScript module that runs in a sandbox and subscribes to the customer events that Shopify publishes. This page explains both in full: the file layout, the block schema, every block target, how merchants activate the blocks and how an app detects that, the deprecation of script tags with the dates that now apply, the pixel sandbox and its APIs, how pixels are activated for a shop, and how consent decides whether any of this code runs.

The page sits after the pages on sections and theme blocks (which explain the @app block type from the theme side) and on apps and scaffolding (which explain shopify.app.toml and the CLI), and it uses the access scopes described in authentication and access scopes. The running example is the Pocket Coffee companion app. It ships an app block that shows the roast date of the beans on the product page (the "Roast date badge"), an app embed block with a small Coffee Club widget that used to be a script tag, and a web pixel that reports add-to-cart and purchase events to a first-party collector. All three respect the visitor’s consent choices.

This page was prepared without a Shopify account, so no extension was ever deployed, previewed in a theme editor or activated in a store. What did run locally is listed in What was run and what was not: the shopify app build command on a complete app with one theme app extension and one web pixel extension, Theme Check on the extension and on a theme that accepts app blocks, deliberately broken variants of both, the bundled pixel executed in a Node harness with fake events, the consent-gated storefront script executed with a stubbed Customer Privacy API, and every GraphQL document validated against the downloaded 2026-10 Admin schema. Everything that needs a store, such as shopify app dev, shopify app deploy, the theme editor and real consent banners, is documented from the official pages and was not executed.

Script tags, theme edits and the two replacements

Before the extension types existed, an app had three ways to reach the storefront, and the page needs the vocabulary of all three because most real apps still contain at least one.

Technique What it did Status on 2026-10-27

Asset API edits

The app wrote a snippet into the merchant’s published theme and edited layout, template or section files to render it.

The legacy path that the migration guide asks apps to leave. Uninstalling the app leaves the edited code (ghost code) behind, and the edits take the merchant’s theme off its automatic upgrade path.

Script tags

The app registered a remote JavaScript file with the ScriptTag resource. Shopify injected it into storefront pages (and, until 2025 and 2026, the Order status page).

Deprecated. scriptTagCreate and scriptTagUpdate fail from 2026-10-01 on every API version; Shopify stops injecting script tags into storefronts on 2027-03-01.

Theme app extensions

The app ships blocks (Liquid), assets and snippets in an extension. Shopify renders them and merchants control them in the theme editor.

Current, and required for any App Store app that integrates with a theme.

Web pixel extensions

The app ships a sandboxed JavaScript module that subscribes to customer events.

Current, and the replacement for script tags that only collect data.

The migration guide states the App Store rule plainly: an app that integrates with a Shopify theme and is submitted to the Shopify App Store must use theme app extensions. Built for Shopify (BFS), Shopify’s badge for apps that meet its quality criteria, adds that such an app must uninstall cleanly using theme app extensions; on 2025-09-25 Shopify extended the grace period for apps that stop meeting that criterion to 60 days before the badge is removed.

Script tag timeline

Script tags are being removed in two separate steps, one for each place they ran. The dates below come from the script tag deprecation pages and the developer changelog entry of 2026-08-24.

Date What happens

2025-02-01

Apps can no longer create script tags whose display_scope is order_status or all.

2025-08-28

Script tags stop running on the Order status page for Plus stores.

2026-08-26

Script tags stop running on the Order status page for all other stores.

2026-10-01

scriptTagCreate and scriptTagUpdate return a permission error on every API version, and POST and PUT requests to the REST ScriptTag resource fail the same way. Pinning an older API version does not defer it. Reads and deletes keep working so that an app can audit and clean up. Existing script tags keep running.

2027-03-01

Shopify stops injecting script tags into storefront pages. Leftover records have no effect after that date, so deleting them is optional.

Two details of the schema are worth knowing. The ScriptTagDisplayScope enum has three values in the 2026-10 schema: ONLINE_STORE, and the deprecated ALL and ORDER_STATUS (the latter "unavailable as a mutation input"). And the scriptTagCreate mutation is still present in the schema and not marked deprecated there, so a document that creates a script tag still validates against the schema; it is the runtime that refuses it. The deprecation page also records that an earlier plan to show a warning banner to merchants who install an app that uses script tags was dropped, so nothing in the admin will tell the merchant that an app is about to stop working.

Which Shopify extension replaces a script tag, by purpose, with the deprecation dates

Choosing the right extension

The deprecation page gives a two-row rule that is easy to remember: a script that loads JavaScript on storefront pages becomes an app embed block, and a script that tracks analytics, conversions or customer behaviour becomes a web pixel. The longer comparison below adds the app block and the other surfaces.

App block App embed block Web pixel

Purpose

Inline content that a merchant positions: reviews, ratings, badges, forms.

Code with no slot of its own: floating widgets, chat bubbles, meta tags, scripts.

Observing customer events and sending them to a service.

Where it runs

Inside a theme section or theme block that accepts @app, or in a wrapper section.

Injected before the closing </head> or </body> tag of every page, or only on chosen templates.

In a sandbox on storefront, checkout, thank you, customer account and Order status pages.

Theme requirement

A JSON template and a section that supports and renders @app blocks (Online Store 2.0).

None: vintage themes work too.

None.

Merchant control

Adds, removes and moves it from the Apps section of the theme editor.

Switches it on under Theme settings then App embeds; off after install.

Listed under Settings then Customer events; connected by the app with webPixelCreate.

Liquid access

Parent section id plus block.settings; autofill resource settings.

Global Liquid scope of the page only.

None: there is no Liquid, only the event payloads.

Consent

Your JavaScript must check the Customer Privacy API.

Your JavaScript must check the Customer Privacy API.

Declared in [customer_privacy]; Shopify’s pixel manager enforces it.

A rule of thumb for the Pocket Coffee app: the roast date badge is an app block, because the merchant wants it next to the product price and the block must follow the product being displayed; the Coffee Club widget is an app embed block, because it floats over every page and has no place in the section layout; the add-to-cart reporting is a web pixel, because it only collects data.

Theme app extensions

A theme app extension is a bundle of app blocks, app embed blocks, assets and snippets that lives in the extensions folder of the app and is deployed with the rest of the app version. Its advantages over editing themes are the ones the documentation lists: the app is exposed in the theme editor automatically, every store gets the new version at the same time through app versions, assets are served from Shopify’s CDN, one set of integration logic works for all themes, and merchants never touch theme code. Extensions are generated with the CLI:

shopify app generate extension --template theme_app_extension --name pocket-coffee-theme

The command needs a logged-in Shopify identity and an app linked to the CLI, so it was not run for this page; in a non-interactive shell without credentials it stops with "To run this command, log in to Shopify". The template it uses is public (theme_app_extension in the Shopify/extensions-templates repository). It generates shopify.extension.toml with name and type, a blocks/star_rating.liquid app block that reads a demo.avg_rating product metafield, a snippets/stars.liquid snippet, a locale file and a thumbs-up image. The tutorial asks you to create that metafield definition (area Products, namespace demo, key avg_rating, type Integer) and give at least one product a value, so that the example block has something to show.

File structure

The generated extension and a populated one look like this. The folder names are fixed; Shopify reads nothing else.

extensions/
  pocket-coffee-theme/
    assets/
      club-widget.js
      club-widget.css
      roast-badge.css
    blocks/
      club_widget.liquid
      consent_defaults.liquid
      roast_date_badge.liquid
    snippets/
    locales/
      en.default.json
      en.default.schema.json
      es.json
    shopify.extension.toml
Item What it holds

assets/

CSS, JavaScript, images and other static files. Everything in the folder is served from Shopify’s CDN. A block loads assets either with the javascript and stylesheet schema attributes or with the asset_url and asset_img_url filters.

blocks/

One Liquid file per block. The file name (without .liquid) is the block’s handle; deep links and activation checks use it. An app block and an app embed block differ only by the target in their schema.

snippets/

Reusable Liquid, included from blocks with render. Theme Check reports include in a theme app extension block as an error (AppBlockValidTags).

locales/

JSON translation files. Storefront locale files (en.default.json, es.json) hold customer-facing strings used with the t filter; schema locale files (en.default.schema.json) hold merchant-facing strings for the theme editor. Merchants cannot edit the strings in storefront locale files.

shopify.extension.toml

The extension’s name and type (see below).

shopify.extension.toml

The configuration file of a theme app extension is deliberately minimal, because the extension’s behaviour comes from the schemas of its blocks.

name = "Pocket Coffee storefront"
type = "theme"
Property Required Description

name

Yes

The name of the extension. It is only seen by the developer; merchants see the name of each block.

type

Yes

Always theme for a theme app extension.

handle

No

A unique reference name for the extension inside the app, used in configuration, APIs and the Dev Dashboard. If you do not set one the CLI can generate it. It names the whole extension and is unrelated to the handle of an individual block, which comes from the block’s file name.

uid

No

Written by the CLI into the files of the newer extension templates; managed by Shopify and not edited by hand.

When the build ran offline, the CLI derived the handle pocket-coffee-storefront from the name and used it as the log prefix (the log line reads pocket-coffee-storefront | Running theme check on your Theme app extension...).

App blocks

An app block shows inline content that the merchant can place. You create one by setting "target": "content" in the schema. The older value section still works and behaves identically, but new blocks should use content. By default a theme has no app blocks after an app is installed: the merchant adds them from the Apps section of the theme editor, either inside a section that accepts app blocks or as a wrapped block of its own at section level.

Use app blocks for apps whose content should follow dynamic sources (reviews and star ratings), for content that merchants may want to reposition, and for content that should span the full page width. They are the right choice for the Pocket Coffee roast date badge:

{%- liquid
  assign product = block.settings.product
  assign roast_date = product.metafields['$app:roast'].roast_date.value
-%}

{%- if product != blank and roast_date != blank -%}
  <p class="pc-roast-badge" style="--pc-badge-color: {{ block.settings.badge_color }};" {{ block.shopify_attributes }}>
    {{- 'roast_badge.label' | t }}: {{ roast_date | date: '%d/%m/%Y' -}}
  </p>
{%- elsif request.design_mode -%}
  <p class="pc-roast-badge pc-roast-badge--empty" {{ block.shopify_attributes }}>
    {{- 'roast_badge.empty' | t -}}
  </p>
{%- endif -%}

{% schema %}
{
  "name": "Roast date badge",
  "target": "content",
  "stylesheet": "roast-badge.css",
  "enabled_on": {
    "templates": ["product"]
  },
  "settings": [
    {
      "type": "product",
      "id": "product",
      "label": "Product",
      "autofill": true
    },
    {
      "type": "color",
      "id": "badge_color",
      "label": "Badge colour",
      "default": "#6f4e37"
    }
  ]
}
{% endschema %}

The block does five things that are typical of app blocks. It reads the product through an autofill resource setting, so that on a product page it points at the product being shown without the merchant choosing it. It reads an app-owned metafield through the reserved prefix $app:roast (see App-owned data: metafields and metaobjects). It prints block.shopify_attributes so that the theme editor can select the block when it is clicked in the preview. It shows an explanatory placeholder only when request.design_mode is true, that is, inside the theme editor, so the merchant can see and position the block even before any product has a roast date. And it declares one stylesheet that Shopify loads once per page however many badges the page contains.

What a theme needs to host app blocks

App blocks only work in themes that contain JSON templates and sections that both support and render blocks of type @app. The theme side is described in the page on sections and theme blocks; the essentials for an app developer are these.

  • A section opts in by listing a generic block of type @app in its schema, and renders the blocks with the content_for 'blocks' tag (or with render block inside a for loop over section.blocks for section-defined blocks). A block of type @app does not accept the limit setting; Theme Check reports Property limit is not allowed if you add one.

  • A section that supports app blocks may contain at most one resource setting of each type as a section setting (one product, one collection), to keep autofill unambiguous.

  • App blocks are not supported in statically rendered sections.

  • When a merchant adds an app block as a top-level block of its own, Shopify wraps it in a wrapper section. The theme can supply sections/apps.liquid, or the more general sections/_blocks.liquid (which accepts both @theme and @app blocks), and both need an @app block type and a preset; without them the theme editor returns an error and merchants cannot use the section (for apps.liquid the messages are "Apps not supported" and "Apps section is invalid"). If neither exists, Shopify falls back to a platform-generated apps.liquid. The apps.liquid schema cannot use the templates attribute, because the wrapper must be available on all templates.

The following section and wrapper were added to the Skeleton reference theme and checked with Theme Check; they are the smallest theme that accepts the roast date badge on its product page.

{%- comment -%} sections/product.liquid (excerpt) {%- endcomment -%}
<div class="product-info">
  <h1>{{ product.title }}</h1>
  <p>{{ product.price | money }}</p>
  <p>{{ product.description }}</p>
  {% content_for 'blocks' %}
</div>

{% schema %}
{
  "name": "t:general.product",
  "settings": [],
  "blocks": [
    {
      "type": "@app"
    }
  ],
  "disabled_on": {
    "groups": ["header", "footer"]
  }
}
{% endschema %}
{%- comment -%} sections/apps.liquid {%- endcomment -%}
<div class="apps-wrapper">
  {% for block in section.blocks %}
    {% render block %}
  {% endfor %}
</div>

{% schema %}
{
  "name": "Apps",
  "settings": [],
  "blocks": [
    {
      "type": "@app"
    }
  ],
  "presets": [
    {
      "name": "Apps"
    }
  ]
}
{% endschema %}

When a merchant adds the badge to the Coffee Club product template, the theme editor saves a reference to the block in the template’s JSON. The reference has the form shopify://apps/<app handle>/blocks/<block handle>/<unique id>:

{
  "sections": {
    "main": {
      "type": "product",
      "blocks": {
        "roast-badge": {
          "type": "shopify://apps/pocket-coffee/blocks/roast_date_badge/00000000-0000-0000-0000-000000000000",
          "settings": {
            "badge_color": "#6f4e37"
          }
        }
      },
      "block_order": ["roast-badge"]
    }
  },
  "order": ["main"]
}

The unique identifier shown here is a placeholder. The product setting is absent from the saved settings because an autofill setting resolves to a dynamic source and does not need a stored value.

Whether a store’s published theme supports app blocks decides which integration to ship, and the "Verify theme support" page shows how to find out. With the read_themes scope the app reads the main theme (themes(first: 1, roles: [MAIN])), fetches the JSON templates it wants to integrate with (templates/product.json, templates/collection.json, templates/index.json), finds the section whose id is main or whose type starts with main-, reads that section’s file, and looks for a block of type @app in its schema. If every template passes, use an app block and do not create a script tag; if not, use an app embed block, which works on vintage themes and in which your JavaScript places content wherever it needs to, as a script tag did. The onboarding differs accordingly: instructions for adding the block in the first case, and instructions for a manual step such as pasting a snippet or adding a custom Liquid block in the second. Only the main section of the default product template is required to support app blocks, although Shopify recommends that every section with blocks does.

App embed blocks

An app embed block holds code that has no UI slot of its own, or that floats or overlays the page: chat bubbles, product image badges, cookie banners, SEO meta tags, analytics and tracking snippets. Shopify renders it and injects it before the closing </head> or </body> tag, depending on the target:

target Where it is injected and what it is for

head

Inside the document head. For meta tags, structured data and scripts that must be early.

compliance_head

Inside the document head, first, ahead of the other head blocks. Reserved for cases where order matters for compliance, for example cookie consent banners and consent defaults that other scripts read.

body

Before the closing body tag. For floating components and scripts that can wait.

App embed blocks are off after installation. A merchant activates them in the theme editor under Theme settings and then App embeds, and an app cannot activate them on the merchant’s behalf; what the app can do is send the merchant to the theme editor with the embed already switched on (see Deep links). They work in vintage themes and in Online Store 2.0 themes alike, because they do not depend on sections or JSON templates, which is why one embed can replace a script tag for every merchant without maintaining a second installation path. The price of that independence is scope: an app embed block can only see the Global Liquid scope of the page it is rendered on, so it cannot use autofill or read a section’s settings. An embed can also limit itself to chosen templates with enabled_on, which a script tag could never do; loading a script only on the pages that need it is one of the performance arguments for the migration.

The Coffee Club widget is the Pocket Coffee example. It replaces a script tag that used to inject a floating "free shipping above 40 EUR for club members" message on every page.

<div
  id="pc-club-widget"
  class="pc-club-widget pc-club-widget--{{ block.settings.position }}"
  data-locale="{{ request.locale.iso_code }}"
  data-currency="{{ cart.currency.iso_code }}"
  data-threshold="{{ block.settings.free_shipping_threshold }}"
  data-consent-purpose="{{ block.settings.consent_purpose }}"
  data-greeting="{{ 'club_widget.greeting' | t | escape }}"
  hidden
></div>

{% schema %}
{
  "name": "Coffee Club widget",
  "target": "body",
  "javascript": "club-widget.js",
  "stylesheet": "club-widget.css",
  "settings": [
    {
      "type": "select",
      "id": "position",
      "label": "Position",
      "default": "bottom-right",
      "options": [
        { "value": "bottom-right", "label": "Bottom right" },
        { "value": "bottom-left", "label": "Bottom left" }
      ]
    },
    {
      "type": "number",
      "id": "free_shipping_threshold",
      "label": "Free shipping threshold (EUR)",
      "default": 40
    },
    {
      "type": "select",
      "id": "consent_purpose",
      "label": "Consent needed before the widget calls our server",
      "default": "analytics",
      "options": [
        { "value": "none", "label": "None (strictly necessary)" },
        { "value": "analytics", "label": "Analytics" },
        { "value": "marketing", "label": "Marketing" }
      ]
    }
  ]
}
{% endschema %}

The markup is only a placeholder div that carries data attributes. The behaviour is in club-widget.js, which the schema loads, and the consent logic of that script is explained in Gating an app embed on consent. The data attributes use only objects that exist in the Global Liquid scope: request.locale, cart.currency and the block’s own settings. The same block could not read product on a product page unless the theme’s Global Liquid scope provides it there, and that is not guaranteed, which is the practical reason to prefer an app block whenever content must follow a resource.

A second embed uses the compliance_head target to set safe defaults before any other script runs:

<script>
  window.pocketCoffee = window.pocketCoffee || {};
  window.pocketCoffee.consent = { analytics: false, marketing: false, ready: false };
</script>

{% schema %}
{
  "name": "Consent defaults",
  "target": "compliance_head",
  "settings": []
}
{% endschema %}

The script that a block loads through the javascript attribute is added to the page head as <script async>, so its position relative to other scripts is not guaranteed; an inline script in a compliance_head block is the way to run something before them. If several blocks on the page reference the same JavaScript or stylesheet file, the file is included once.

The four injection points of theme app extension blocks: compliance_head, head, content inside a section and body

Block schema reference

The schema of an app block or app embed block resembles a theme section schema, with a few attributes of its own.

Attribute Description Required

name

The title shown in the theme editor. Keep it under 25 characters so it fits the sidebar, and do not repeat the app name: the app’s title is shown below the block name in block pickers, the app embeds panel and the app details panel.

Yes

target

Where the block goes: content for app blocks; head, compliance_head or body for app embed blocks; section is a legacy alias of content.

Yes

javascript

A file from assets/ that Shopify loads automatically with <script async> in the page head when the block is present.

No

stylesheet

A file from assets/ that Shopify loads automatically with <link rel="stylesheet"> in the page head when the block is present.

No

enabled_on

Limits an app block to specific templates and section groups, or an app embed block to specific templates. Same shape as in a section schema. Blocks can never be rendered on checkout step pages.

No

disabled_on

The opposite of enabled_on. Use only one of the two.

No

class

Extra CSS classes for the wrapping element. Theme app extensions always add the class shopify-block.

No

tag

If set, wraps the block’s output in that element; otherwise the wrapper is a div.

No

settings

Settings the merchant can edit in the theme editor, read in Liquid through block.settings. Resource settings may carry autofill (app blocks only).

No

default

A default configuration for the block, as in a section schema.

No

available_if

A reference to a boolean app-data metafield that decides whether the block renders and appears in the editor at all.

No

Three notes on the table. First, the documentation asks for schemas to avoid cluttering the theme editor: all app embed blocks of all apps live in one panel, so limit settings to visual ones. Second, app blocks have no presets; they appear in the picker because their target is content. Third, theme app extensions do not support comments or trailing commas in JSON, unlike theme files. A trailing comma in a schema is a parse error here.

Conditional blocks with available_if

A block can be shown only to some stores, for example only to a plan tier or a region. The condition is a boolean app-data metafield that the app writes, read through the Liquid app object:

{% schema %}
{
  "name": "Club members only",
  "target": "content",
  "stylesheet": "app.css",
  "javascript": "app.js",
  "available_if": "{{ app.metafields.conditional.block1 }}",
  "settings": []
}
{% endschema %}

The example uses an app-data metafield of namespace conditional and key block1, which must be of type boolean. If the metafield value is true the block renders and is available in the theme editor; otherwise it is not. The app Liquid object has a single property, metafields, which exposes the metafields owned by the app. The snippet above was not included in the Pocket Coffee extension because the extension has no such metafield; the attribute is documented, not executed.

Autofill

autofill is a setting on resource settings (product, collection, article and similar) of app blocks. When the merchant adds the block to a section, Shopify fills the setting with a dynamic source: the parent section’s resource setting of the same type, or the template’s global resource. A featured product section has a product setting that selects the featured product, and an app block with an autofill product setting automatically uses it; on a product template it uses the product being displayed. That is why a reviews or ratings block never needs an extra step to find the product. The setting does not exist for app embed blocks, because they only have the Global Liquid scope.

Liquid scope and restrictions

App blocks and embeds are rendered in a restricted Liquid environment. The restrictions are not obvious from the theme side and cause most "it works in my theme but not in the extension" problems.

  • They cannot be rendered on checkout pages: contact information, shipping method, payment method and Order status. Checkout customisation uses checkout UI extensions and thank-you page extensions instead.

  • The content_for_header, content_for_index and content_for_layout objects are not available.

  • Of the parent section object only the id property is available. Use it with the Section Rendering API to re-render the section.

  • JSON files cannot contain comments or trailing commas.

  • Since 2026-01-13 Shopify enforces stricter Liquid parsing for all themes and requires new theme store and App Store submissions, including theme app extensions, to be strict-parsable. The changelog describes an automatic rewrite, with an explanatory comment, for non-conforming theme files; for an extension, write strict-parsable Liquid yourself and let Theme Check confirm it.

App-owned data: metafields and metaobjects

An extension often needs data that the app manages, such as the roast date. App-owned metafields and metaobjects use the reserved prefix $app, and the extension can reference them without hardcoding an app ID:

{{ block.settings.product.metafields['$app:roast'].roast_date.value }}
{{ shop.metaobjects['$app:custom']['my_handle'].my_key.value }}

Using the prefix means the same Liquid works in a development app and in the production app, which have different IDs. For the badge, the app creates the definition once and then sets a value per product. Both mutations were validated against the downloaded 2026-10 schema:

mutation PocketCoffeeRoastDefinition {
  metafieldDefinitionCreate(
    definition: {
      name: "Roast date"
      namespace: "$app:roast"
      key: "roast_date"
      type: "date"
      ownerType: PRODUCT
      access: { storefront: PUBLIC_READ }
    }
  ) {
    createdDefinition {
      id
      namespace
      key
    }
    userErrors {
      code
      field
      message
    }
  }
}

mutation PocketCoffeeSetRoastDate($ownerId: ID!) {
  metafieldsSet(
    metafields: [
      {
        ownerId: $ownerId
        namespace: "$app:roast"
        key: "roast_date"
        type: "date"
        value: "2026-10-20"
      }
    ]
  ) {
    metafields {
      id
    }
    userErrors {
      field
      message
    }
  }
}

Metafield definitions and ownership rules are the subject of the page on metafields and metaobjects; whether a storefront can read the value (the access.storefront field above) is part of the definition. The Liquid reads the value the merchant-facing way, so the badge never needs an API call at render time.

Assets, performance and size limits

Everything under assets/ is served from the Shopify CDN, so the extension never needs its own static hosting. The extension is validated when it is deployed; if it exceeds an enforced limit, validation fails and the extension does not update.

App content Limit Enforced or suggested

All files in a theme app extension

10 MB

Enforced

Number of blocks

30

Enforced

Number of locale files in the extension

100

Enforced

Size of each locale file

15 KB

Enforced

Size of Liquid across all files

100 KB

Enforced

Size of CSS (compressed) referenced directly by the schema

100 KB

Suggested

Size of JS (compressed) referenced directly by the schema

10 KB

Suggested

The block limit was 25 until 2026-02-03, when Shopify raised it to 30 app blocks per extension. Because app embed blocks can restrict themselves to the templates that need them, the documentation recommends that they load scripts only on specific pages, which keeps the performance cost of the app proportionate to its use; a script tag always loaded on every page.

Building the Pocket Coffee extension offline

The Pocket Coffee extension (three blocks, three assets, two locale files and a schema locale file) was assembled in a scratch app with a placeholder client ID of 32 zeros and example.invalid URLs, and built with the CLI. The command needs neither a login nor a store:

export SHOPIFY_CLI_NO_ANALYTICS=1
shopify app build --no-input

The output (trimmed) shows that the build runs Theme Check on the extension and then bundles it:

 pocket-coffee-storefront | Running theme check on your Theme app extension...
 pocket-coffee-storefront | Bundling theme extension pocket-coffee-storefront...

╭─ success ────────────────────────────────────────────────────────────────────╮
│  pocket-coffee-app built!                                                    │
╰──────────────────────────────────────────────────────────────────────────────╯

Theme Check can also be run directly in the extension folder, which is what a CI job should do (the next section explains why). With the three blocks above it printed 6 files inspected with no offenses found.

A build that passes with errors

A deliberately broken block was added to a copy of the extension (an unused assign, an img without dimensions, a reference to a missing asset, an include of a missing snippet and a parser-blocking script tag with a remote source). The result is worth remembering:

  • shopify app build printed every Theme Check finding with its location, and still ended with built! and exit code 0.

  • shopify theme check in the extension folder reported the same findings as 5 errors and 3 warnings and exited with code 1.

Check Severity Finding in the broken block

ImgWidthAndHeight

error

Missing width and height attributes on img tag

AppBlockValidTags

error

Theme app extension blocks cannot contain include tags

ParserBlockingScript

error

Avoid parser blocking scripts by adding defer or async

MissingAsset

error

assets/logo.png does not exist

MissingTemplate

error

snippets/nope.liquid does not exist

DeprecatedTag

warning

Use the render tag instead of include

RemoteAsset

warning

The asset should be served by the Shopify CDN

UnusedAssign

warning

The variable is assigned but not used

So a pipeline that only runs shopify app build will not stop on Theme Check errors in this version of the CLI. Run shopify theme check inside each theme app extension folder as a separate step, or read its JSON output (-o json, which has valid, issues and errorCount). The same run showed the limits of the offline check: a block with "target": "footer", a duplicated setting id and a name of 38 characters was not reported by Theme Check. The schema is validated when the extension is deployed, so keep the schema table of this page next to you while writing blocks.

Previewing, testing and deploying

The sequence that needs a store is the one in the official tutorial: shopify app dev starts a local development server with hot reloading and uploads a host theme to the development store (Shopify’s example theme Dawn unless you choose one with --theme <ID or name>), and the CLI output tells you to open the host theme in the theme editor, add your app block, save and open the printed preview URL. The preview is available only in Google Chrome, and the local server port can be set with --theme-app-extension-port. The tutorial lists Ruby and Bundler among the project dependencies. Test with both an app block and an app embed, and check the things the tutorial names: floating components do not cover page content, merchants can switch embeds on and off and edit their settings, and blocks can be added, removed and moved.

Releasing works like any other extension. shopify app deploy creates an app version (a snapshot of the app configuration and all extensions) and releases it, replacing the active version served to installed stores; it can take several minutes for stores to be upgraded. --no-release creates the version without releasing it, and --version and --message name it. The page on billing, App Store requirements and deployment covers CI for shopify app deploy.

shopify app dev --theme "Horizon"
shopify app deploy --message "Roast date badge and Coffee Club embed" --no-release

Neither command was executed for this page; both need an authenticated CLI and an app registered in the Dev Dashboard.

Because blocks are inactive until a merchant acts, the app must lead the merchant to the right place and must know whether the merchant got there. Both are part of the migration from script tags: deleting the script tag before the replacement is active leaves a gap in which neither works.

After installation the app can send the merchant to the theme editor with a block already added or an embed already activated. The merchant sees a preview and still has to save. The query parameters come from the app and the block.

Parameter Meaning

{template}

The JSON template to open (product, collection, article, blog, index and so on). If absent, index.

{api_key}

The app’s API key, equal to client_id in shopify.app.toml. (The older uuid parameter, taken from the SHOPIFY_<EXTENSION_NAME>_ID entry in .env, is deprecated.)

{handle}

The file name of the block’s Liquid file without extension, for example roast_date_badge.

{header|footer|aside}

Section group to add the block to (app blocks only).

{sectionId}

Id of a section that supports app blocks, written with the prefix sectionId: (app blocks only).

context

For embeds: always apps.

The URL shapes are these. target=newAppsSection adds the block as a top-level block in a new Apps section, target=mainSection adds it to the section whose id is main, target=sectionGroup:header (or footer, aside) adds it to a group, and target=sectionId:<id> adds it to a specific section. An embed is activated with activateAppId.

https://<your-shop>.myshopify.com/admin/themes/current/editor?template=product&addAppBlockId=<api_key>/roast_date_badge&target=mainSection

https://<your-shop>.myshopify.com/admin/themes/current/editor?template=index&addAppBlockId=<api_key>/announcement_bar&target=sectionGroup:header

https://<your-shop>.myshopify.com/admin/themes/current/editor?context=apps&template=index&activateAppId=<api_key>/club_widget

Retrieve the shop domain from the Shop object of the GraphQL Admin API, and keep the placeholders above out of any public repository: <your-shop> and <api_key> are placeholders. A deep link can add only one app block at a time, only to sections that support app blocks, and the merchant is shown a coach mark after the block is added. If the deep link cannot be satisfied the merchant gets one of these messages:

Scenario How to prevent it

Section limit reached in the template or group

Cannot be prevented; the merchant is invited to remove a section and try again.

Template not found, section group not found, template is Liquid, section does not support blocks or app blocks

Check the theme with the read_themes scope before offering the link. If the section is not found or does not support app blocks, the block falls back to being added to the template.

Template type is not supported by the block’s schema

Link only to templates allowed by the block’s enabled_on and disabled_on.

Typo in the URL

Test every deep link.

Template has no resource to render (no product, collection or article)

Cannot be prevented; the merchant is invited to create one.

Block limit reached in a section

Cannot be prevented; the merchant is invited to remove a block.

Detecting whether a block is active

There are two ways to learn whether the merchant added a block or enabled an embed, and the documentation recommends them in this order.

From inside the Shopify admin, call shopify.app.extensions() of the App Bridge App API. It needs no extra access scope and returns, for each extension of the app, the activation status of every block on the published theme. A status of active means the block is on the published theme; available means the app ships it but the merchant has not enabled it. Since 2026-01-23 the method covers theme app extensions as well as admin, checkout and customer account extensions.

const APP_EMBED_HANDLE = 'club_widget';

const extensions = await shopify.app.extensions();

const themeExtensions = extensions.filter(
  (extension) => extension.type === 'theme_app_extension',
);

const isActive = themeExtensions.some((extension) =>
  extension.activations.some(
    (block) => block.handle === APP_EMBED_HANDLE && block.status === 'active',
  ),
);

Because the method runs in the browser, the answer only exists when a merchant opens the app, so record it if you need it later. A block’s handle is its file name, which is why the deep link and the check use the same string.

From the app’s backend, read the published theme’s config/settings_data.json and look for your block type. This needs the read_themes scope and works without waiting for the merchant. An embed appears in settings_data.json only after it has been enabled once; if it is disabled afterwards it stays in the file with "disabled": true. App blocks appear in the JSON template that hosts them, with the shopify://apps/... type shown earlier. The GraphQL query below reads the file of the main theme; it validates against the 2026-10 schema:

query PocketCoffeeEmbedState {
  themes(first: 1, roles: [MAIN]) {
    nodes {
      id
      name
      files(filenames: ["config/settings_data.json"]) {
        nodes {
          filename
          body {
            ... on OnlineStoreThemeFileBodyText {
              content
            }
          }
        }
        userErrors {
          filename
          code
        }
      }
    }
  }
}

The content is a JSON string; parse it and look under current.blocks for an entry whose type starts with shopify://apps/pocket-coffee/blocks/club_widget/ and whose disabled is not true. Remember that an embed is active only on the theme on which it was enabled: if the merchant publishes a different theme, check again.

Merchant experience and UX guidelines

Shopify’s UX guidance for theme app extensions is short, and following it is a condition for a good review.

  • App blocks should be responsive to the width of the section they are added to and inherit the theme’s typography and colours rather than hardcode their own.

  • App blocks in the header should have an icon and a text version so they fit inline, keep the interface the same on desktop, tablet and mobile, show icons no larger than 24 by 24 px, and give them a 44 by 44 px hit box.

  • Limit settings to visual ones, particularly for embeds, which all share one panel.

  • Merchants must be able to add, remove and reposition app blocks and edit their settings from the Apps section, and activate, deactivate and edit app embeds from the App embeds section, previewing before they save.

  • The App Store listing must say whether the app needs an Online Store 2.0 theme.

  • Give clear post-install instructions on the app’s onboarding page: ask which theme the merchant uses, because the instructions differ between Online Store 2.0 and vintage themes; explain how to add, remove and reorder app blocks, which templates they support and how to configure them; and for embeds provide a deep link and the list of templates on which the embed will appear.

  • When a merchant uninstalls the app, Shopify removes its blocks from online store themes automatically and entirely.

Migrating from script tags

The migration is mechanical once you know what the script does. The Pocket Coffee app had one script tag, registered years ago with scriptTagCreate, that loaded club-widget.js from the app’s own server and injected the floating Coffee Club message. The steps follow the deprecation page and the migration guide.

  1. Audit. List the script tags the app registered for each store with the scriptTags query. Custom apps maintained per client must be checked store by store, with a token the app already holds. Reads and deletes continue to work after 2026-10-01.

  2. Build the replacement. A theme app extension with an app embed block for a script that loads JavaScript, an app block if the content should be placed by the merchant (this works only on Online Store 2.0 themes), or a web pixel if the script only collects data. For themes that do not support app blocks, the migration guide says to keep a legacy onboarding flow.

  3. Stop writing. When a store’s published theme supports app blocks, the app must skip all POST requests to the Asset and ScriptTag resources.

  4. Release before 2026-10-01. After that date a store that installs the app has no script tag and no extension, so it gets nothing unless the replacement is already live.

  5. Send the merchant to the theme editor with the embed activated, using a deep link.

  6. Confirm that the embed is active on the published theme, with shopify.app.extensions() or the backend check.

  7. Delete the script tag with scriptTagDelete. Running both at once loads the script twice and can double-count analytics events or render the app’s interface twice.

query PocketCoffeeScriptTags {
  scriptTags(first: 50) {
    nodes {
      id
      src
      displayScope
      cache
      createdAt
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

mutation PocketCoffeeRemoveScriptTag($id: ID!) {
  scriptTagDelete(id: $id) {
    deletedScriptTagId
    userErrors {
      field
      message
    }
  }
}

Both documents validated against the 2026-10 schema. The ScriptTag object has the fields id, src, displayScope, cache, createdAt, updatedAt and legacyResourceId.

Two groups need special treatment. Script tags with display_scope of order_status or all also fall under the Order status page schedule, whose replacements are customer account UI extensions (for visual customisation, on upgraded stores only) or web pixels (any store, but they cannot render UI). And apps created directly in the Shopify admin (admin-created custom apps) cannot use app extensions at all, so they have no extension-based replacement. If you maintain such an app, move the script into the theme with a custom Liquid block where possible, or the code editor if you must, remembering that a script in theme code stays after the app is gone and that editing theme code takes the theme off its automatic upgrade path. If an agency maintains it, they must do that; if a third-party service asked the store to create the app and paste a token into the service’s dashboard, the service must release a real app.

Web pixels

A web pixel is a JavaScript module that runs on the store’s pages, subscribes to the customer events that Shopify publishes, and forwards them wherever the app needs: an analytics service, an advertising platform or the app’s own collector. Shopify’s design goal is to remove the need for merchants to paste tracking code, to give the developer only the data the pixel is entitled to, and to avoid the performance and privacy problems of arbitrary scripts that scrape or rewrite the page. The benefits listed in the documentation are the following: eliminating or minimising manual tracking code, secure access to all surfaces (storefront, checkout and post-purchase pages), control over what data the developer can access, no performance or privacy alerts, and smaller pixel libraries because the DOM-manipulation code is gone.

How a pixel receives events

Once the pixel has been created for a shop, it is loaded on that shop’s online store. As a customer browses, they trigger customer events, which Shopify publishes to a data layer, or event bus. A pixel subscribes to the events it cares about, transforms the payloads into the format of its endpoint, and sends them. The surfaces are not limited to the storefront: since 2025-07-21 web pixels also load automatically on customer account pages and on the Order status page, where the page_viewed event and the Advanced DOM API are supported and custom events can be published from a UI extension. Shops must use a custom domain for customer accounts (for example accounts.your-store.com) for the customer account pages to log events.

Three kinds of pixels

App pixel (web pixel extension) Custom pixel Server pixel

Who creates it

An app developer, deployed with the app and connected per shop by the app.

The merchant, in the admin under Settings then Customer events.

An app, through the Admin API.

Sandbox

strict: a web worker.

lax: an iframe with the sandbox attribute set to allow-scripts allow-forms.

None in the browser: events are delivered to a cloud endpoint.

Meant for

Apps that serve many merchants.

Merchants who paste legacy third-party tracking code.

Delivery to a cloud endpoint; the Admin API has serverPixelCreate, serverPixelDelete, eventBridgeServerPixelUpdate and pubSubServerPixelUpdate.

Settings

settings object defined in shopify.extension.toml, set with webPixelCreate.

No settings property.

Configured through the mutations.

Privacy

Declared in [customer_privacy] in the toml.

Set in the pixel’s settings in the admin.

Not covered by this page.

Shopify says it strongly recommends that developers build apps and encourage merchants to install them instead of creating custom pixels, for greater integration. A custom pixel in the lax sandbox cannot reach the top frame, so some properties return different values: for example window.href returns the sandbox URL rather than the top frame’s URL. The rest of this section is about app pixels. The existence and signatures of the four server pixel mutations were confirmed in the downloaded 2026-10 schema; their behaviour was not exercised.

Creating a web pixel extension

A web pixel extension is generated with the CLI template web_pixel and offers JavaScript and TypeScript flavours:

shopify app generate extension --template web_pixel --name my-web-pixel

The command needs a login and was not run. The template it copies is public: a shopify.extension.toml, a package.json that depends on @shopify/web-pixels-extension (the template pins ^2.10.0; npm resolved 2.18.0 on 2026-10-27) and src/index.js that registers a callback and subscribes to page_viewed. The Pocket Coffee extension keeps that shape:

name = "Pocket Coffee pixel"
type = "web_pixel_extension"
runtime_context = "strict"

[customer_privacy]
analytics = true
marketing = false
preferences = false
sale_of_data = "disabled"

[settings]
type = "object"

[settings.fields.endpoint]
name = "Collector endpoint"
description = "HTTPS endpoint that receives Pocket Coffee events"
type = "single_line_text_field"
validations = [
  { name = "min", value = "8" }
]
Property Required Description

type

Yes

Always web_pixel_extension.

name

Yes

The name of the pixel extension.

uid

Yes

Created and managed by Shopify, generated when the CLI scaffolds the extension; not edited by hand. (The example above omits it because the extension was written by hand; the CLI adds it when it links the extension to the app.)

runtime_context

Yes

The context the pixel runs in. Must be strict.

customer_privacy

No

The privacy purposes the pixel needs. If the table is missing it defaults to analytics = true, marketing = true and sale_of_data = "enabled". To override the default you must declare all four keys.

customer_privacy.analytics

Yes, if the table is present

true if the pixel collects information about how customers use the site.

customer_privacy.marketing

Yes, if the table is present

true if the pixel provides ads and marketing communications based on customer interests.

customer_privacy.preferences

Yes, if the table is present

true if the pixel persists customer preferences or personalises the experience.

customer_privacy.sale_of_data

Yes, if the table is present

enabled: the pixel counts as data sale or sharing and will not fire when a customer opts out of it. disabled: it does not count, and fires even after an opt-out. ldu: it counts as data sale but implements a "limited data use" mode, so it fires after an opt-out.

settings

Yes

The definition of the settings object. type must be object.

settings.fields

Yes

One table per field. Each has a name and a type, and optionally a description and validations. The only supported type is single_line_text_field. Validation options follow the metafield validation list.

To run a pixel without consent (strictly necessary), set analytics, marketing and preferences to false. Whether a pixel is genuinely essential is a legal question for the merchant, not a configuration choice.

What the offline build checks

The extension was built together with the theme extension. The CLI bundled the pixel in about 20 to 60 ms and printed pocket-coffee-pixel successfully built in 56ms (0.7 KB original, ~0.4 KB compressed); the bundle is a single IIFE of 759 bytes in which register(...) has been replaced by a call to shopify.extend("WebPixel::Render", ...). Then the toml was changed in a scratch copy, one defect at a time:

Change Offline shopify app build result

sale_of_data = "yes"

Error: [customer_privacy.sale_of_data]: Invalid enum value. Expected 'enabled' | 'disabled' | 'ldu', received 'yes'

the analytics key removed from [customer_privacy]

Error: [customer_privacy.analytics]: Required

the runtime_context line removed

Error: [runtime_context]: Required

runtime_context = "lax" or "sandbox"

Accepted by the offline build

a settings field with type = "number_integer"

Accepted by the offline build

The last two rows show where the offline checks stop. The Admin API has an error code INVALID_RUNTIME_CONTEXT ("the provided runtime context is invalid") and the docs state that strict is the only valid value, so treat the value as checked later, when the extension is deployed or the pixel is created, and do not rely on the build to catch it.

The sandbox

App pixels run in a strict sandbox: a web worker. It offers many of the globals of a browser worker, but only the following are guaranteed:

  • self, a reference to the global object.

  • console, the same console as the browser. Shopify asks that production pixels do not log anything.

  • setTimeout, clearTimeout, setInterval and clearInterval.

  • fetch, Headers, Request and Response. Requests to arbitrary endpoints are allowed, but each endpoint must explicitly support CORS, exactly as for fetch() in a normal worker. Use keepalive: true for requests that must survive page unload.

Other globals may be set to undefined or removed at any time, so a pixel must not rely on them. There is no document or window: anything that scrapes the DOM or writes to it is impossible, which is the point. The sandbox replaces the useful parts of those APIs with explicit, asynchronous ones:

Browser feature Pixel replacement

document.cookie

browser.cookie.get(name) and browser.cookie.set(cookieOrName, value), which run in the top frame and return promises.

localStorage, sessionStorage

browser.localStorage and browser.sessionStorage with clear, getItem, key, length, removeItem and setItem, all asynchronous.

navigator.sendBeacon

Deprecated in the pixel API; use fetch with keepalive: true.

document.location, document.title, document.referrer, navigator.userAgent, window.innerWidth

A read-only snapshot in init.context at page render, and in event.context at the moment of each event. document has characterSet, location, referrer and title; location has hash, host, hostname, href, origin, pathname, port, protocol and search; navigator has cookieEnabled, language, languages and userAgent; window has sizes, offsets and screen.

Reading the cart or customer from the page

init.data.cart, init.data.customer, init.data.purchasingCompany and init.data.shop, a snapshot at page render.

The Standard API

The callback passed to register receives one object, the Standard API, which can be destructured. Its properties for app pixels are:

Property What it provides

analytics

analytics.subscribe(eventName, callback), which returns a promise. The event names are the standard event names, your own custom event names, and four wildcards: all_events, all_standard_events, all_custom_events and all_dom_events. The membership of the wildcards can change as events are added, so do not depend on it for contracts.

browser

Asynchronous cookie, localStorage and sessionStorage access in the top frame; sendBeacon is deprecated.

init

A snapshot of the page when it rendered: context (document, navigator, window), data (cart, customer, purchasingCompany, shop) and customerPrivacy.

settings

The settings object saved with webPixelCreate or webPixelUpdate, a string-keyed map (Record<string, any>). App pixels only; custom pixels do not have it.

customerPrivacy

The consent API of the sandbox: customerPrivacy.subscribe('visitorConsentCollected', callback). It is the only event the API accepts at present.

Source: Standard API

Customer events

Every event delivered to a pixel has the same envelope: id (event id), name, type (standard, custom, dom or advanced-dom), seq (sequence number), timestamp (ISO 8601), clientId (Shopify’s client-side id of the visitor), context (the browser snapshot) and data (the payload, specific to the event). The standard events are these:

Event When it fires

page_viewed

A customer visited a page. Available on the online store, checkout, Order status and customer account pages; customer account pages log it only if a vanity domain is set up.

product_viewed

A customer visited a product page.

collection_viewed

A customer visited a collection page.

search_submitted

A customer performed a search on the storefront.

cart_viewed

A customer visited the cart page.

product_added_to_cart

A customer added a product to the cart. data.cartLine is a CartLine with cost.totalAmount, merchandise (a ProductVariant with sku, price, product, title) and quantity, or null.

product_removed_from_cart

A customer removed a product from the cart.

checkout_started

A customer started the checkout process.

checkout_contact_info_submitted

A customer submitted the contact step of checkout.

checkout_address_info_submitted

A customer submitted their mailing address.

checkout_shipping_info_submitted

A customer chose a shipping rate.

payment_info_submitted

A customer submitted payment information.

checkout_completed

A visitor completed a purchase. It fires once per checkout, normally on the Thank you page; for upsells and post purchases it fires on the first upsell offer page instead and not again on the Thank you page; if that page fails to load, it does not fire at all. data.checkout carries currencyCode, totalPrice, subtotalPrice, lineItems, order (with id, which is null for every event except this one) and more.

alert_displayed

A customer saw an alert message, inline validation message or warning banner.

ui_extension_errored

An extension failed to render because of an uncaught exception.

Source: Standard Events

Besides standard events, the DOM Events API provides a subset of interaction events (clicked, form_submitted, input_blurred, input_changed, input_focused) across storefront, checkout and thank you pages (not on customer account pages or the Order status page), and an Advanced DOM Events API exists for finer interaction tracking.

Custom events and the visitor API

Storefront code, theme app extensions, checkout UI extensions and customer account UI extensions can publish their own events, which every app pixel and custom pixel can subscribe to. Partners and merchants cannot publish standard events. The online store exposes the method on the Shopify global:

Shopify.analytics.publish('pocket_coffee:club_widget_shown', { locale: 'en' });

Source: Emitting data

Prefix custom event names with the app name or another unique identifier (my_app:event_name) so they cannot collide with standard events. In a pixel the payload arrives in event.customData. Custom events can be published by users, other developers and even visitors from the browser console, so treat them as untrusted input. The Shopify.analytics.visitor method lets a storefront script pass an email address or phone number the visitor volunteered (for example to receive a discount code) so that Shopify and apps can use it; pass appId so that data quality does not degrade, and make sure the necessary notices and consent have been given. The Coffee Club widget publishes pocket_coffee:club_widget_shown after it has been displayed with consent (see the consent section), which is the link between the two extension types.

The Pocket Coffee pixel

The pixel reports two events to the app’s own collector: every add-to-cart, and the completed purchase. The endpoint is a setting, so each shop can point to a different collector, and the Spanish and English storefronts are told apart by the /en path prefix.

import {register} from '@shopify/web-pixels-extension';

register(({analytics, init, settings}) => {
  const endpoint = settings.endpoint;
  const locale = init.context.document.location.pathname.startsWith('/en') ? 'en' : 'es';

  const send = (name, payload) =>
    fetch(endpoint, {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({name, locale, payload}),
      keepalive: true,
    });

  analytics.subscribe('product_added_to_cart', (event) => {
    const line = event.data.cartLine;
    if (!line) return;
    send('add_to_cart', {
      sku: line.merchandise.sku,
      quantity: line.quantity,
      amount: line.cost.totalAmount.amount,
      currency: line.cost.totalAmount.currencyCode,
    });
  });

  analytics.subscribe('checkout_completed', (event) => {
    const checkout = event.data.checkout;
    send('purchase', {
      orderId: checkout.order ? checkout.order.id : null,
      total: checkout.totalPrice.amount,
      currency: checkout.currencyCode,
      lines: checkout.lineItems.map((item) => ({
        sku: item.variant.sku,
        quantity: item.quantity,
      })),
    });
  });
});

The tutorial’s own sample sends a variable called payload that it never defines; the version above builds the payload from the event. The collector at https://collector.example.test/events is a reserved test domain, so no request would ever leave a developer’s machine with this example; the endpoint must also answer CORS preflight requests, because the sandbox is a worker.

To check the logic without a store, the bundle produced by shopify app build was executed in a Node vm context with a fake shopify.extend, a fake analytics.subscribe that records the callbacks, and a fake fetch. The harness registers the pixel, then fires one add-to-cart for two 250 g Ethiopia bags (SKU PC-ETH-WHL-250), one add-to-cart with cartLine set to null, and one purchase of a 1 kg Colombia filter bag plus the starter kit. It printed:

registered for WebPixel::Render
subscribed to product_added_to_cart, checkout_completed
[
  { "url": "https://collector.example.test/events",
    "body": { "name": "add_to_cart", "locale": "en",
              "payload": { "sku": "PC-ETH-WHL-250", "quantity": 2, "amount": 19.8, "currency": "EUR" } },
    "keepalive": true },
  { "url": "https://collector.example.test/events",
    "body": { "name": "purchase", "locale": "en",
              "payload": { "orderId": "gid://shopify/Order/1001", "total": 53.9, "currency": "EUR",
                           "lines": [ { "sku": "PC-COL-FIL-1KG", "quantity": 1 },
                                      { "sku": "PC-KIT", "quantity": 1 } ] } },
    "keepalive": true }
]

Source: Web Pixels API

The null cart line produced no request, as intended. The harness does not reproduce the real sandbox: it proves that the logic reads the documented event fields correctly, not that Shopify delivers those events or that the endpoint accepts them.

Activating a pixel for a shop

Deploying the extension does not start the pixel. The app also needs two access scopes, write_pixels (to write the pixel’s merchant settings) and read_customer_events (to get access to customer events), and it must create a web pixel record on every shop where it is installed. In shopify.app.toml:

[access_scopes]
scopes = "read_products,write_pixels,read_customer_events"

When shopify app dev connects the extension to a development store, the app’s name appears under Settings then Customer events in the App pixels list with the status Disconnected. Creating the record flips it to Connected. The mutation validates the settings against the definition in shopify.extension.toml; if they do not match, it fails.

mutation PocketCoffeeCreatePixel($settings: JSON!) {
  webPixelCreate(webPixel: { settings: $settings }) {
    webPixel {
      id
      settings
    }
    userErrors {
      code
      field
      message
    }
  }
}

mutation PocketCoffeeUpdatePixel($id: ID!, $settings: JSON!) {
  webPixelUpdate(id: $id, webPixel: { settings: $settings }) {
    webPixel {
      id
      settings
    }
    userErrors {
      code
      field
      message
    }
  }
}

mutation PocketCoffeeDeletePixel($id: ID!) {
  webPixelDelete(id: $id) {
    deletedWebPixelId
    userErrors {
      code
      field
      message
    }
  }
}

query PocketCoffeeCurrentPixel {
  webPixel {
    id
    settings
  }
}

All four documents validated against the 2026-10 schema. The input type WebPixelInput has a single field, settings (JSON), so the variables for the Pocket Coffee shop are:

{
  "settings": "{\"endpoint\":\"https://collector.example.test/events\"}"
}

Note that settings is a JSON value that carries an encoded JSON string, as in the official tutorial. A deliberately wrong document, with a field runtimeContext on WebPixelInput and a misspelt settngs on WebPixel, was rejected by validation (Field "runtimeContext" is not defined by type "WebPixelInput" and Did you mean "settings"?). The userErrors of the pixel mutations use code values from the ErrorsWebPixelUserErrorCode enum:

Code Meaning in the 2026-10 schema

BLANK

The input value is blank.

TAKEN

The input value is already taken.

NOT_FOUND

The record with the id could not be found.

INVALID_SETTINGS

The settings do not match the settings definition of the app.

INVALID_CONFIGURATION_JSON

The settings are not valid JSON.

INVALID_SETTINGS_DEFINITION

The settings definition of the pixel extension is in an invalid state on the app.

INVALID_RUNTIME_CONTEXT

The runtime context is invalid.

NO_EXTENSION

No extension was found for the app.

UNABLE_TO_DELETE

An error occurred and the pixel could not be deleted.

UNEXPECTED_ERROR

An unexpected error occurred.

A reasonable pattern is to query webPixel first and call webPixelUpdate when a record exists and webPixelCreate otherwise, and to run the same step from the app’s installation hook and from the "Enable tracking" button in the app.

Protected customer data in pixel events

Since 2025-12-10 Shopify enforces its protected customer data policy for all web pixel extensions on every surface (storefront, checkout and customer accounts). Personally identifiable fields (name, email, phone and address) are present in the payload only if the app has been approved for the corresponding protected scope; otherwise the field is null and the structure of the event does not change. The scopes are read_customer_name, read_customer_email, read_customer_phone, read_customer_address and read_customer_personal_data. Custom pixels are out of scope of this change. For the Pocket Coffee pixel, which sends SKUs, quantities and amounts, no PII is read, so no approval is needed; an app that forwards checkout.email must handle null without breaking its pipeline, and should request only the scopes it uses.

Testing and debugging a pixel

With a development store the loop is the one in the tutorial: run shopify app dev, create the web pixel record, open the store, open the browser’s developer tools and read the console, where the tutorial logs the accountID setting. Use the store’s Customer events page to confirm the status of the pixel. Remove console.log calls before release, because Shopify asks that production pixels do not log. Without a store, keep the logic of the pixel in plain functions and test them as the harness above does; the bundle is a plain script, so a fake shopify.extend is enough to drive it.

Every script that touches a visitor’s browser or sends data about them has a legal basis, and Shopify’s tools for it are the same for both extension types: the Customer Privacy API on the storefront and the [customer_privacy] declaration of a pixel. The material below describes what the tools do; what a particular merchant’s obligations are depends on their markets and is a question for counsel, as the Customer Privacy API documentation itself warns.

Shopify distinguishes four processing purposes:

Purpose Meaning Check in the storefront API

Preferences

Remembering choices such as language, currency or size to personalise the visit.

preferencesProcessingAllowed()

Analytics

Understanding how customers use the site and the interactions they make.

analyticsProcessingAllowed()

Marketing

Marketing, attribution and targeted advertising from the merchant.

marketingAllowed()

Sale of data

Sharing data with third parties, usually for behavioural advertising.

saleOfDataAllowed()

Whether a purpose is allowed for a given visitor combines three things: the merchant’s current privacy settings (is consent required in this region?), the visitor’s location, and what the visitor has consented to. For regions that are configured to require consent, non-essential purposes are not allowed until the visitor consents. For other regions the default is to allow everything. The merchant configures regions and the cookie banner in the admin’s privacy settings; data sale opt-out regions, usually US states, are configured separately, and in them the Global Privacy Control signal is honoured automatically and sets sale_of_data to false.

For app pixels consent is enforced for you. The pixel declares its purposes in [customer_privacy], and Shopify’s pixel manager loads the pixel only if the visitor’s permission covers all the purposes the pixel declares as required. The behaviour at the callback level depends on the region:

  • Consent required. Callbacks run only after consent is given. Then all previously registered events are replayed, so the pixel catches up with what happened on the page before the visitor accepted.

  • Tracking on by default, visitor may opt out. Callbacks run as events are registered until the visitor opts out, and not afterwards.

Web pixel event path from the storefront through the event bus and the consent gate into the strict sandbox, with the two consent behaviours

A pixel cannot call the Customer Privacy API itself. If it needs to adapt to consent changes (for example to include the consent state in payloads), it reads init.customerPrivacy for the state at page load and subscribes to customerPrivacy.subscribe('visitorConsentCollected', ...) for later changes. The event payload carries the four flags analyticsProcessingAllowed, marketingAllowed, preferencesProcessingAllowed and saleOfDataAllowed.

import {register} from '@shopify/web-pixels-extension';

register(({analytics, init, customerPrivacy}) => {
  let consent = init.customerPrivacy;

  customerPrivacy.subscribe('visitorConsentCollected', (event) => {
    consent = event.customerPrivacy;
  });

  analytics.subscribe('product_added_to_cart', (event) => {
    const payload = {
      eventName: event.name,
      customerPrivacyStatus: consent,
    };
    fetch('https://collector.example.test/events', {
      method: 'POST',
      body: JSON.stringify(payload),
      keepalive: true,
    });
  });
});

Custom pixels have the same API through api.customerPrivacy and init.customerPrivacy, and set their privacy requirements in the pixel’s settings in the admin instead of a toml file. The visitorConsentCollected event of the pixel sandbox and the document event of the storefront share a name but not an identical payload: the storefront documentation shows analyticsAllowed and preferencesAllowed in the detail, while the pixel API shows the ...ProcessingAllowed names. Read the exact keys from the documentation of the surface you are on, and log one event in a development store before relying on a key.

The Customer Privacy API in the storefront

Theme app extension code that reads or changes consent loads the API first. The feature name and version are exact:

window.Shopify.loadFeatures(
  [
    {
      name: 'consent-tracking-api',
      version: '0.2',
    },
  ],
  (error) => {
    if (error) {
      // the API did not load
      return;
    }
    // window.Shopify.customerPrivacy is now available
  },
);

After the callback runs without an error, the API is on window.Shopify.customerPrivacy. The methods are:

Method Purpose

analyticsProcessingAllowed(), marketingAllowed(), preferencesProcessingAllowed(), saleOfDataAllowed()

Return true or false: may this purpose be processed now? They combine merchant settings, location and consent, and are the right calls to gate code.

currentVisitorConsent()

Returns the visitor’s decisions: {marketing, analytics, preferences, sale_of_data} with values 'yes', 'no' or the empty string (not yet decided). It does not include location or merchant configuration, so it must not be used to decide what is allowed.

setTrackingConsent(consent, callback)

Records the visitor’s decision. consent has analytics, marketing, preferences (booleans) and, separately, sale_of_data, plus optional metafields. Only call it as a result of a visitor interaction, never automatically on the visitor’s behalf.

shouldShowBanner()

true if consent is not yet set and the visitor is in a region that shows a cookie banner. It does not say whether consent was already given.

saleOfDataRegion()

true if the visitor is in a region configured for data sale opt-out.

getRegion()

The detected region as an ISO 3166-2 style string, such as USCA, CAON, IEL or GBENG; empty if unknown.

consentId()

The visitor’s consent ID, useful to look up their consent history in the merchant’s audit log.

getTrackingConsentMetafield(key)

Reads a custom value stored with setTrackingConsent.

The document event visitorConsentCollected is published when consent changes, with the new state in event.detail; updates are published only when consent changes, not when the listener is added. An app that renders its own banner calls setTrackingConsent with the visitor’s choice:

window.Shopify.customerPrivacy.setTrackingConsent(
  { analytics: true, marketing: false, preferences: true },
  () => console.log('Consent captured'),
);

document.addEventListener('visitorConsentCollected', (event) => {
  console.log(event.detail);
});

Four rules apply. Never read or modify Shopify cookies directly; the API is the only supported interface, and cookie names and formats change. Shopify stopped setting the _tracking_consent, _landing_page and _orig_referrer cookies on storefronts on 2025-09-15; the replacement for _tracking_consent is this API, and for the other two the browser’s window.location.href and document.referrer, or the Web Pixels API, or the customerJourneySummary of an order. Record a data sale opt-out only through a flow the visitor starts (a link or preference page), because the flag is true to allow and false to block and must not be applied on page load. And when the Shopify cookie banner is enabled it shows in the configured regions and passes consent to the API automatically; if the merchant uses an app for the banner, ask that app’s developer where the banner is rendered. The cookie banner has a small runtime API of its own: privacyBanner.showPreferences() opens the preferences modal.

An app embed that loads for everyone must itself decide what it may do before consent. The Coffee Club widget does the following, and club-widget.js is the file the schema loads:

(function () {
  'use strict';

  var root = document.getElementById('pc-club-widget');
  if (!root) { return; }

  var purpose = root.dataset.consentPurpose || 'analytics';
  var allowedKey = { analytics: 'analyticsProcessingAllowed', marketing: 'marketingAllowed' }[purpose];

  function allowed() {
    if (purpose === 'none') { return true; }
    var api = window.Shopify && window.Shopify.customerPrivacy;
    return Boolean(api && typeof api[allowedKey] === 'function' && api[allowedKey]());
  }

  function render() {
    root.textContent = root.dataset.greeting;
    root.hidden = false;
  }

  var started = false;

  function maybeStart() {
    if (started || !allowed()) { return; }
    started = true;
    render();
    if (typeof window.Shopify.analytics !== 'undefined' && window.Shopify.analytics.publish) {
      window.Shopify.analytics.publish('pocket_coffee:club_widget_shown', { locale: root.dataset.locale });
    }
  }

  function boot() {
    maybeStart();
    document.addEventListener('visitorConsentCollected', maybeStart);
  }

  if (purpose === 'none') { boot(); return; }
  if (!window.Shopify || typeof window.Shopify.loadFeatures !== 'function') { return; }
  window.Shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.2' }], function (error) {
    if (error) { return; }
    boot();
  });
})();

The script follows the documented pattern: load the feature, test the purpose with the ...Allowed method that combines merchant settings, location and consent, subscribe to visitorConsentCollected for a later decision, and do nothing that needs consent until it is true. The merchant chooses the required purpose in the block settings; none is for a widget that is strictly necessary, and the default is analytics. The started flag matters: without it the script would render again and publish the custom event again every time a consent event arrived, including in the none mode.

The script was executed in a Node vm context with a stubbed window.Shopify (a loadFeatures that calls back at once, a customerPrivacy whose analyticsProcessingAllowed is switched by the test, an analytics.publish that records its calls) and a stubbed document. It printed:

  loadFeatures [{"name":"consent-tracking-api","version":"0.2"}]
purpose=analytics: after load hidden=true
  after consent: hidden=false text="Coffee Club members get free shipping above 40 EUR" published=[["pocket_coffee:club_widget_shown",{"locale":"en"}]]
purpose=none: after load hidden=false
  after consent: hidden=false text="Coffee Club members get free shipping above 40 EUR" published=[["pocket_coffee:club_widget_shown",{"locale":"en"}]]

The widget stays hidden while analytics are not allowed and appears once consent arrives; in none mode it appears at once and publishes once. This is a logic test with stubs: it does not exercise Shopify’s real banner, region detection or the real Shopify global. If the widget is shown to every visitor regardless of consent, as many sites do for strictly necessary messages, the none setting makes that explicit and visible to the merchant.

Three further pieces complete the picture.

  • Server-side opt-out. The Admin API mutation dataSaleOptOut(email:) opts a customer out of data sale on the server side. It validated against the 2026-10 schema and returns the customerId and userErrors.

  • Headless storefronts. A storefront built outside the Online Store does not have the Shopify banner. For custom storefronts the Customer Privacy API documentation points to Hydrogen’s analytics and consent handling, which manages the session on the server so consent persists across page loads and is honoured by checkout, and is also the only way to integrate a cookie banner fully with Shopify Analytics. Direct callers pass headlessStorefront, checkoutRootDomain, storefrontRootDomain and storefrontAccessToken to setTrackingConsent. For the Storefront API itself, the @inContext directive accepts a visitorConsent argument (since 2025-09-02) with analytics, preferences, marketing and sale of data; the consent is encoded into the checkoutUrl that cart operations return as the _cs parameter, so Checkout Kit and other integrations carry the visitor’s choice into checkout.

  • Cookie-banner apps. If the merchant uses a consent app, the app is responsible for calling setTrackingConsent with the visitor’s choices; the pixel and embed code described above then see consent through the same API.

mutation PocketCoffeeOptOut($email: String!) {
  dataSaleOptOut(email: $email) {
    customerId
    userErrors {
      code
      field
      message
    }
  }
}

Source: dataSaleOptOut

Pocket Coffee: putting it together

The companion app now covers the three jobs. Each one is a separate extension piece with its own activation step, which is the practical lesson of this page: the theme editor activates blocks, the Admin API activates pixels, and the app’s onboarding page must tell the merchant which is which.

Job Extension piece Activation Consent

Show the roast date on product pages

App block roast_date_badge (content, enabled_on the product template, autofill product, app-owned metafield $app:roast.roast_date)

The merchant adds it from Apps in the theme editor, or follows a mainSection deep link; the app checks shopify.app.extensions() for the block’s activation.

None: it shows data the merchant already publishes.

Floating Coffee Club message (formerly a script tag)

App embed block club_widget (body) with club-widget.js and club-widget.css, plus consent_defaults (compliance_head)

The merchant switches it on under App embeds, or follows an activateAppId deep link; the script tag is deleted only after the check passes.

The block setting selects the purpose; the script waits for analyticsProcessingAllowed() and listens for visitorConsentCollected.

Report add-to-cart and purchases

Web pixel pocket-coffee-pixel (strict, analytics = true, sale_of_data = "disabled")

webPixelCreate with the collector endpoint, after the app has the write_pixels and read_customer_events scopes.

Declared in [customer_privacy]; Shopify’s pixel manager loads the pixel only when analytics is allowed.

The Spanish and English storefronts are served by the same blocks: the badge and widget strings come from locales/es.json and locales/en.default.json, and the pixel reports the locale from the path prefix. The second currency does not change the extension code, because the pixel reads the currency from each event’s own money fields.

Troubleshooting

Symptom Likely cause and fix

The app embed does not appear on the storefront.

App embeds are off after install and are active only on the theme where they were enabled. Send the merchant to the theme editor with an activateAppId deep link, and re-check activation after a theme change.

The app block is missing from the block picker.

The theme has no JSON template for that page, or the section does not list { "type": "@app" } in its blocks, or the block’s enabled_on or disabled_on excludes the template. Check with the read_themes verification.

A top-level app block shows "Apps not supported" or "Apps section is invalid" in the theme editor.

The theme’s apps.liquid or _blocks.liquid lacks an @app block type or a preset.

Property limit is not allowed in a theme.

An @app block entry in a section schema contains limit; remove it.

The extension deploys but its schema is rejected.

Offline checks do not cover every schema rule. Review the schema table: valid target values, the 30-block limit, unique setting ids, JSON without comments or trailing commas.

shopify app build printed errors but succeeded.

It does not fail on Theme Check errors in this version. Run shopify theme check in the extension folder and fail the pipeline on its exit code.

The script runs twice.

A script tag and its replacement are both active. Delete the script tag after the embed is confirmed active.

Nothing works for new installs after 2026-10-01.

The app still creates script tags, which now fail. Ship the replacement before the date and remove the creation code.

The pixel does not fire.

The record was never created (webPixelCreate), the settings do not match the definition (INVALID_SETTINGS), the visitor has not consented to a declared purpose, or the page is a customer account page on a shop without a custom domain, where events are not logged.

Pixel events have null names, emails or addresses.

The app is not approved for the matching protected customer data scope. Handle null, or request the scope.

fetch from the pixel fails.

The endpoint does not support CORS; the sandbox is a worker. Add the CORS response headers, including for preflight.

document is not defined in a pixel.

Pixels have no DOM. Use init.context, event.context, browser.cookie and the event payloads.

The consent API is undefined in an embed.

loadFeatures has not called back yet, or failed. Wait for the callback, and handle the error branch.

Versions, dates and limits

Item Value Date or source

Shopify CLI used for the offline builds

5.0.0

npm latest, 2026-10-27

GraphQL Admin API used to validate the documents

2026-10

released 2026-10-01

@shopify/web-pixels-extension

2.18.0 (template pins ^2.10.0)

npm, checked 2026-10-27

Web Pixels API, Customer Privacy API, Liquid

Unversioned, may change at any time

API versioning page

Customer Privacy API feature

consent-tracking-api version 0.2

Customer Privacy API page

App blocks per extension

30 (25 before)

2026-02-03

Extension size limits

10 MB total, 100 KB Liquid, 100 locale files of 15 KB each

Configure theme app extensions

Script tag create and update blocked

2026-10-01

Storefront script tags page

Script tags no longer injected into storefronts

2027-03-01

Storefront script tags page

Order status page script tags stop

2025-08-28 (Plus), 2026-08-26 (others)

Order status script tags page

Web pixels on customer accounts and Order status

2025-07-21

Developer changelog

Protected customer data enforced in pixels

2025-12-10

Developer changelog

Stricter Liquid parsing for themes and extensions

2026-01-13

Developer changelog

shopify.app.extensions() covers theme app extensions

2026-01-23

Developer changelog

_tracking_consent, _landing_page, _orig_referrer cookies no longer set

2025-09-15

Developer changelog

@inContext visitorConsent

2025-09-02

Developer changelog

What was run and what was not

Everything below ran on 2026-10-27 on macOS with Node 24, Shopify CLI 5.0.0 and no store, account, login or credential. The scratch app used the placeholder client ID 00000000000000000000000000000000 and https://example.invalid URLs.

What Result

shopify app build --no-input on an app with one theme app extension (three blocks) and one web pixel extension

Built; Theme Check ran on the extension; the pixel bundled to 759 bytes.

shopify theme check in the extension folder

6 files inspected with no offenses found.

The same with a broken block

shopify app build printed the findings and still reported success (exit 0); shopify theme check reported 5 errors and 3 warnings and exited 1.

Skeleton reference theme with @app support added to the product section plus sections/apps.liquid

40 files inspected with no offenses found; adding limit to the @app entry gave Property limit is not allowed.

Web pixel toml variants

Missing key, bad sale_of_data and missing runtime_context rejected; lax and a non-text field type accepted offline.

The bundled pixel in a Node vm with fake events

Two requests as expected; the null cart line was ignored.

The consent-gated embed script with a stubbed Customer Privacy API

Hidden until consent in analytics mode; shown once in none mode.

GraphQL documents against the downloaded Admin 2026-10 schema (script tag audit and delete, web pixel create/update/delete/query, theme file read, metafield definition and set, data sale opt-out)

All valid; a deliberately wrong pixel document was rejected.

shopify app generate extension, shopify app dev, shopify app deploy, the theme editor, activation, deep links, the Customer events admin page and real consent banners

Not run: they need a Shopify identity, a store or both. They are documented from the official pages.

No secret-like material was used or found; the only identifiers are the placeholder client ID, example.invalid and example.test hosts and a placeholder block id of zeros.