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 thestorefrontsurface, 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.
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.
# 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... 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 ofstorefront,checkout,quote.consentCategory—essential,functional,analyticsormarketing. The script does not mount at all until the buyer has granted that category.protectedData—noneorhashed. See Buyer data.externalDomains— hostnames the script may reach. Enforced as a Content Security Policy, which matches hosts exactly:google-analytics.comdoes not coverwww.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 ablockedoutcome withreason: "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,protectedDataorexternalDomainsis refused with409 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— asha256in a merchant's inventory has to be thesha256that 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.
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.
// 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.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 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.
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.hashed—emailSha256andphoneSha256, each a SHA-256 of the lowercased, trimmed value. This is the form GA4 and Meta's advanced matching want.
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.
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.
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 anactiveinstall, 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.
/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.blockedandscript.errorare merchant-facing events. They requireapplication_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.