Extensions

Scripts an Extension Ships

An extension can ship JavaScript that runs on the merchant's buyer-facing pages — hosted checkout, quote pages, and the merchant's own storefront. This is how a pixel, an analytics relay, a fraud signal or an announcement bar reaches real buyers without the merchant pasting code into a theme.

Read this after your first private install. For the merchant's view of the same feature — and for a merchant writing their own script rather than installing an app — see Your Own Scripts.

The two tiers

Pick the tier when you declare the script. It decides where the code can run and how hard it is to get approved.

  • sandbox — runs inside a null-origin sandboxed iframe with no access to the host page, its cookies or its storage. Allowed on every surface, including checkout. This is the default and what almost every app should use.
  • dom — runs directly on the merchant's storefront page with full access to it. Restricted to the storefront surface, refused on checkout by the API, the manifest schema and a database constraint, and it requires a staff review of the source for every change.
A dom-tier script raises the bar for your whole version
DOM-tier code is unsandboxed third-party JavaScript on every installed merchant's storefront. Nothing contains it, so it never auto-approves — a human reads the source. Use sandbox unless you genuinely need the page, and expect longer review turnaround when you do not.

Declaring a script

A script has two halves: a declaration, one per (extension, key), carrying what the script is allowed to do; and source, which is per extension version. Source is attached to a draft version and frozen when you publish it.

From the CLI
# Cut a draft version first (dashboard, or POST .../versions),
# then attach source to it.
throttle extensions scripts push ext_01H... \
  --version-id ver_01H... \
  --key ga4-enhanced \
  --name "GA4 enhanced conversions" \
  --source ./scripts/ga4.js \
  --surfaces checkout,quote \
  --consent analytics \
  --pii hashed \
  --domains www.google-analytics.com

throttle extensions scripts list ext_01H...
Or directly
POST /api/v1/extensions/ext_01H.../scripts
x-api-key: <your key>

{
  "extensionVersionId": "ver_01H...",
  "key": "ga4-enhanced",
  "name": "GA4 enhanced conversions",
  "source": "register(async (api) => { /* ... */ });",
  "tier": "sandbox",
  "surfaces": ["checkout", "quote"],
  "consentCategory": "analytics",
  "protectedData": "hashed",
  "externalDomains": ["www.google-analytics.com"]
}

// 201 — the key was new on this extension
// 200 — the key existed; its source on that draft version was replaced
  • key — a stable lowercase handle. It identifies the script forever; merchants see it in their script inventory.
  • surfaces — any of storefront, checkout, quote.
  • consentCategoryessential, functional, analytics or marketing. The script does not mount at all until the buyer has granted that category.
  • protectedDatanone or hashed. See Buyer data.
  • externalDomains — hostnames the script may reach. Enforced as a Content Security Policy, which matches hosts exactly: google-analytics.com does not cover www.google-analytics.com. One leading wildcard label is allowed — *.klaviyo.com — for a vendor SDK whose host set is the vendor's to change; it covers every subdomain and not the bare domain. A request the CSP refuses is reported as a blocked outcome with reason: "csp", so it is diagnosable from the merchant's script health view. The list is frozen once a version containing the script is published.

Shipping an update

Pushing a key that already exists replaces its source on the target draft version — that is how you ship a new build. The response is 201 for a key that was new and 200 when an existing one was replaced.

  • Capabilities freeze once the script has shipped. After any version containing the script is published, a push that changes tier, surfaces, consentCategory, protectedData or externalDomains is refused with 409 script_published_capabilities_frozen. Those fields live on the declaration and take effect immediately on every install already pinned to an approved version, so changing one would bypass review. Ship the new behaviour under a new key.
  • Source-only pushes are always fine while the target version is a draft. Bug fixes do not need a new key.
  • A published version is frozen. Attaching to one returns 409 extension_version_published — a sha256 in a merchant's inventory has to be the sha256 that actually ran.
  • A new version inherits the previous version's scripts. Creating a version copies every non-deleted script forward byte-for-byte, so a version that changes nothing keeps shipping what you already shipped. Push only what changed. Deleting the declaration is how you stop shipping a script.
Consent on upgrade
A version that adds a script key the merchant does not already run requires them to re-consent, with acknowledgedNewScripts on the upgrade call. A version that drops one does not re-prompt: the merchant ends up running less, which needs no permission.

Writing the script

A sandbox-tier script runs inside a null-origin iframe. Four bindings exist at the top level: register, analytics, browser and settings. document, window, parent, top, opener, localStorage and sessionStorage are undefined.

A sandbox-tier script
// Top-level bindings: register, analytics, browser, settings.
// `api` is ONLY the parameter of the register callback — using it at the top
// level throws "api is not defined".
register(async (api) => {
  const id = settings.measurementId;          // merchant-entered, per install
  if (!id) return;                            // nothing to do, not an error

  api.analytics.subscribe('checkout.completed', async (event) => {
    const body = new URLSearchParams({
      v: '2',
      tid: id,
      en: 'purchase',
      'ep.order_id': String(event.data.orderNumber ?? ''),
      // Present only because this script declared protectedData: "hashed".
      ...(event.data.emailSha256 ? { sha256_email_address: event.data.emailSha256 } : {}),
    });

    // Allowed because www.google-analytics.com is in externalDomains.
    await fetch('https://www.google-analytics.com/g/collect?' + body, { method: 'POST' });
  });
});
`api` is the callback's parameter, not a global
api.browser at the top level of your file throws api is not defined, and the merchant sees an error in their health panel with no other clue. Use the bare bindings at the top level, or api.* inside the register callback.

Settings — one script, different merchants

Every install can carry its own values for your script. A merchant sets them on the installed app's Scripts tab, and they arrive as settings in a sandbox script or as a data- attribute on a dom-tier tag.

A dom-tier script reading its settings
// A dom-tier script gets the real page, so it reads its own configuration
// the way any vendor tag does.
(function () {
  var tag = document.currentScript;
  var settings = JSON.parse(tag.dataset.throttleSettings || '{}');
  var key = tag.dataset.throttleScriptKey;

  var bar = document.createElement('div');
  bar.textContent = settings.message || 'Free shipping over $50';
  document.body.prepend(bar);
})();
  • Missing settings are an empty object, never an error. Handle the empty case by doing nothing rather than throwing.
  • The same tab lets a merchant switch one of your scripts off without uninstalling your app. Treat that as normal.
Settings reach the buyer's browser
They are handed to code running on a public page, and on the dom tier they are an attribute in the page's own DOM. Never ask a merchant to put a secret in them — use your install-time API key and your own backend for anything privileged.

Buyer data

What a script receives on checkout.completed depends entirely on the protectedData grade it declared.

  • none — no buyer identifiers at all. The default, and correct for most scripts.
  • hashedemailSha256 and phoneSha256, each a SHA-256 of the lowercased, trimmed value. This is the form GA4 and Meta's advanced matching want.
There is no plaintext grade
A third grade, raw, used to be accepted and stored — and delivered exactly what none delivers. An app could declare it, pass review, and then receive no identifiers at all with nothing explaining why. It is now refused with a message pointing at hashed. If your integration genuinely needs plaintext, it needs a grade that does not exist yet, and that is a consent and data-protection conversation rather than a field.

Loading a vendor library

Stored source is capped at 128KB, so a script is a stub that loads the library rather than the library itself.

browser.loadScript
register(async (api) => {
  // https only, and the host must match a declared externalDomains entry
  // EXACTLY — the CSP matches hosts, not parent domains.
  await api.browser.loadScript('https://fpjscdn.net/v3/your-key/iife.min.js');

  // The library is now a global inside this sandbox frame, nowhere else.
  const fp = await FingerprintJS.load();
  const { visitorId } = await fp.get();
  api.analytics.publish({ name: 'custom.risk.signal', ts: Date.now(), data: { visitorId } });
});

A vendor snippet that expects the real document — Google Tag Manager, most tag managers — will not work on the sandbox tier. Forward the events you need to the vendor's HTTP endpoint instead, as the GA4 example above does.

Cookies and storage

browser.cookie, browser.localStorage and browser.sessionStorage are proxied through the host page. Storage keys are namespaced per script; cookies are not.

Reads resolve, writes reject
register(async (api) => {
  // Reads settle as null when refused — a denied read genuinely is "no value".
  const existing = await api.browser.cookie.get('_my_app_id');

  // Writes REJECT when refused, so a failure cannot read as a success.
  try {
    await api.browser.cookie.set('_my_app_id', 'abc', { maxAgeSeconds: 86400 });
  } catch (err) {
    // Reserved name, value over 4KB, a ';' in the value, or a bad maxAge.
    // Uncaught, this surfaces as a script.error in the merchant's health panel.
  }
});
  • Reserved names are refused: __throttle*, __session, __clerk*, _vercel*, and the browser's own __Host- / __Secure- prefixes.
  • Cookie values are capped at 4KB and may not contain ; or control characters; storage values are capped at 64KB.

Review

Submitting a version runs a static preflight over every script's source. Hard failures block the submission; soft flags are surfaced to the reviewer alongside the code.

  • Hard failures include reaching a host you did not declare (script_undeclared_domain) and touching a forbidden API (script_forbidden_api).
  • Soft flags include dynamic member access (script_computed_member) — legal, but it defeats static analysis, so a human looks harder.
  • The reviewer sees a per-script diff against your currently approved version: which fields moved, the previous sha256, and the full source.
  • Obfuscated or minified source is not a hard failure, but it makes approval slower. Ship readable code.

Install lifecycle — when your scripts start and stop

There is no separate cleanup step to call, and nothing is deleted when a merchant removes your app. Whether your scripts run is resolved on every page load from the installation row, so the install's state is the switch.

  • Install pins one published version. From the next page load, the scripts that version ships resolve for that application and environment — subject to consent, the surface, and the merchant's own per-script switches.
  • Uninstall marks the installation uninstalled. Resolution requires an active install, so every script your app ships disappears at once — from checkout, quote pages, the storefront, and the merchant's script inventory. Nothing is left behind to clean up, and no script rows are deleted.
  • Suspend behaves the same way for scripts: not active, so nothing resolves.
  • Reinstall restores them. The installation row is reused, so a merchant who uninstalls and reinstalls gets the same scripts back with their per-script settings intact.
  • Takedown (staff) cascades an uninstall across every active install and clears the approved version, so a dom-tier script loses two of its conditions at once. Hosted checkout stops immediately; a storefront stops within one 60-second cache window.
Uninstall stops execution — it does not retract the source
Script bodies are served from an immutable, content-addressed, public URL (/es/<scriptId>/<sha256>.js) that is cached indefinitely and needs no authentication. After an uninstall nothing tells a browser to fetch it, but anyone who already has the URL can still read the code. Treat your script source as public, because it is.

Limits

  • 128KB of source per script, and at most 10 scripts per surface on one extension.
  • Load order is not guaranteed. Scripts mount independently; do not depend on another one having run.
  • Your scripts cannot subscribe to another app's published events, and cannot read the host page on the sandbox tier.
  • script.loaded, script.blocked and script.error are merchant-facing events. They require application_scripts:read, which is not grantable to an extension, so an app cannot subscribe to the health of its own scripts. Report what you need from inside the script instead.
  • Outcomes are recorded on every surface — checkout, quote pages and the merchant's storefront — so what a merchant sees in their health panel is what actually happened wherever your script ran. A storefront outcome needs the loader to hold storefront_scripts:write; a publishable key minted before that scope existed simply reports nothing.