Your Own Scripts on Hosted Checkout
Add your own JavaScript to Throttle's hosted checkout page — a pixel, a cookie-based attribution stitcher, a custom analytics relay. It runs in an isolated sandbox with no access to the page, the payment form, or anything Throttle does not explicitly hand it.
/c and /s), and never in embed mode.
See
What is not in this release
before you build against this.
What this is
Throttle mounts every enabled script for a surface inside its own <iframe sandbox="allow-scripts"> —
no allow-same-origin, so the frame's
origin is opaque and it cannot reach the parent document under any
circumstance. Your script talks to the host only through postMessage, wrapped by the small API
surface described below.
| Surface | Where it mounts | What you have to do |
|---|---|---|
checkout | Hosted checkout — /c/<sessionId> and /s/<sessionId> | Nothing. Mounted for you. |
quote | The hosted quote page, /q/<token> | Nothing. Consent fails closed here — a quote link carries no consent decision, so an environment that requires consent runs essential scripts only. |
storefront | Your own pages, on your own domain | Install @usethrottle/scripts and allow-list your origin — see Your own storefront. |
Embedded checkout (?embed=1) | Runs no scripts of its own. It forwards its events to your page instead. | Install the loader on the page doing the embedding. Without it, checkout-surface scripts never run — the dashboard warns when it has never seen your loader call in. |
Embedded checkout runs nothing itself on purpose: a sandbox inside that
iframe would write cookies like _ga to
Throttle's checkout domain, splitting them from the storefront half of
the same shop. Forwarding the events to your own loader keeps every
cookie first-party on your domain.
What a script can and cannot do
| Can | Cannot |
|---|---|
Subscribe to checkout lifecycle events (checkout.started, .step_changed, .payment_submitted, .completed, .failed) and publish its own events over analytics.subscribe / analytics.publish — see the event catalog below. | Touch the checkout page — no document, window, or parent. Reachable identifiers are shadowed to undefined; the real containment is the iframe's opaque origin. |
Read/write cookies (real names — _ga, _fbp, etc. — with a small reserved list blocked) and namespaced local/session storage, all proxied async through the host. A read settles as null when refused; a write rejects, so a refused write cannot read as a successful one. | Read the payment form, card fields, or anything else rendered on the page. |
Call fetch / sendBeacon to hostnames it declared in externalDomains. | Make a network request (fetch, sendBeacon, XHR, images) to any other domain — blocked by the sandbox document's CSP connect-src, with no error the script itself can see; Throttle reports it as a blocked outcome with reason: "csp". A script can still navigate its own frame (e.g. location.href = ...) to any URL — that is not a network request CSP governs. Throttle detects this: a navigated frame is torn down and reported as an error outcome, so it is observable in the script health view even though it is not prevented. |
| Read the page context it booted with (URL, referrer, title, user agent) once, at load. | See anything about the buyer or the order that was not explicitly included in an event payload — no PII grading is wired yet, so no event carries customer data in this release regardless. |
The register() contract
Your source is compiled as a function body and run, so plain
JavaScript works — top-level statements execute, and a script
that never calls register() is
perfectly valid. Use it when you want to subscribe to events; skip it
when you just want code to run.
At the top level you are handed four bindings: register, analytics, browser and settings. There is no api at the top
level — api is the single
object passed into the register callback, so api.browser.cookie.set(…) outside register() is a ReferenceError. Write browser.cookie.set(…) instead.
What actually constrains you is not the convention. document, window, parent, top, opener, localStorage and sessionStorage are undefined inside a sandbox-tier script,
and the network is governed by the CSP built from your externalDomains. Use browser.* for storage and browser.loadScript() for vendor tags.
Your script's whole body is one call to the global register() function, which Throttle
invokes once per load with { analytics, browser, init, settings }.
The shape below — subscribing to a named event and publishing a derived
one — is copied from a real, currently-passing test in packages/scripts-runtime/src/sandbox/bootstrap.test.ts,
not invented for this page:
// The wire contract, proven in
// packages/scripts-runtime/src/sandbox/bootstrap.test.ts — register() takes
// one callback and is invoked once, at load, with { analytics, browser, init, settings }.
register(({ analytics }) => {
analytics.subscribe('checkout.completed', (event) => {
analytics.publish({
name: 'saw',
ts: Date.now(),
data: { id: event.data.orderId },
});
});
});
This actually fires: the hosted checkout page bridges its own checkout
lifecycle onto this bus (a script never sees the underlying
postMessage events — only the projected
checkout.* names below).
Loading a vendor tag
browser.loadScript(url) pulls a remote
script into the sandbox and resolves when it has run. The host must be in
your externalDomains, exactly
— declaring example.com does not cover cdn.example.com, because the CSP matches
hosts rather than parent domains. It rejects with a real error you can
catch, rather than leaving you with a CSP refusal the page cannot see.
// externalDomains: ["www.googletagmanager.com"]
register(async ({ browser, analytics }) => {
try {
await browser.loadScript('https://www.googletagmanager.com/gtag/js?id=G-XXXX');
// The tag has run. Anything it defined is now on the sandbox's global.
} catch (err) {
// Undeclared host, http, or the vendor was down — all catchable.
analytics.publish({ name: 'custom.tag_failed', ts: Date.now(), data: { message: String(err) } });
}
}); A vendor tag mutates whenever the vendor redeploys — that is the point of one — so subresource integrity cannot pin it and no review can vouch for what runs tomorrow. What contains it is the sandbox: the code lands in an opaque origin with no cookies, no access to the merchant's DOM, no parent, and no network beyond the same declared list. Judge a vendor on whether you are willing to run their code under those limits, not on having read it once.
Event catalog
| Event | Trigger | Payload (event.data) |
|---|---|---|
checkout.started | The checkout page mounts. | {} — empty. |
checkout.step_changed | The buyer moves between checkout steps. | { step } — one of 'cart' | 'address' | 'shipping' | 'billing' | 'payment'. |
checkout.payment_submitted | The card payment form is submitted (Gr4vy embed — the widget used by both /c and /s for card payments). | {} — empty. |
checkout.completed | Payment is captured. | { orderId, paymentId }, plus paymentStatus and subscriptionId when applicable, plus total (minor units), currency, and lineItems (each { id?, name, sku?, quantity, unitPrice, total }) when the session has a cart. |
checkout.failed | The card payment is declined, cannot be authorized, or the payment form itself fails to load or submit. | { code, message } — code is one of embed_load_failed, transaction_not_authorized, transaction_failed, caller_complete_failed, caller_complete_threw, complete_failed, complete_threw. |
Gr4vyEmbedFrame, the card-payment
widget shared by /c and /s.
A Net30 payment attempt never renders that widget, so it does not fire
either event — a Net30 checkout still fires checkout.completed
on success, but a script cannot yet observe a Net30 attempt being
submitted or rejected.
Independent of any event, code can also run at boot — register() fires once as soon as the
script loads, no subscription needed:
// Runs today: fires once at load, no event required. Forward to your OWN
// domain (declared in externalDomains) — never call a vendor endpoint that
// takes a secret directly from inside the sandbox; see the callout below.
register(async ({ browser, init }) => {
const visitorId = (await browser.cookie.get('_ga')) ?? crypto.randomUUID();
fetch('https://analytics.example.com/collect', {
method: 'POST',
body: JSON.stringify({
event: 'checkout_viewed',
visitorId,
url: init.context.location.href,
referrer: init.context.referrer,
}),
});
}); api_secret,
Meta's Conversions API access token) must never see that secret embedded
in a script. Declare your own domain in externalDomains, relay to it, and hold
the real secret server-side — or use
Throttle's first-party tracking settings
for GA4/Meta specifically, which already keep those secrets server-side
for you.
settings is always {}
for a merchant-authored script in this release — there is no per-install
configuration surface yet (that lands with marketplace app scripts).
Bake any configuration your script needs directly into its source.
Consent
Every script declares one of four consent categories: essential, functional, analytics, or marketing. essential
always runs. Every other category fails closed: no consent signal means no script — the same rule
Throttle's existing GA4/Meta tracking settings already use, so both
systems degrade the same way for a buyer who has not answered a consent
prompt.
Consent reaches the hosted page the same way it reaches
first-party tracking
— a consent=granted or consent=denied query parameter on the
checkout URL. With requireConsent off
(you run your own consent platform upstream), an absent parameter grants
everything except an explicit denied,
which always wins.
External domains
externalDomains is a list of hostnames
— analytics.example.com, not https://analytics.example.com/collect.
No scheme, no port, no path. Each entry is validated against one shared
rule at write time, again when the sandbox document's
Content-Security-Policy is composed, and again inside the frame by browser.loadScript, so the write-time
rule can never drift out of sync with what the browser actually enforces.
Hosts match exactly: example.com
does not cover static.example.com. For a
vendor SDK whose host set is its own to change — most analytics and
marketing tags publish *.vendor.com as
their CSP guidance, not a fixed list — declare one leading wildcard
label: *.example.com. It becomes https://*.example.com in the CSP, which
the browser enforces as written: every subdomain at any depth, never the
bare domain (declare that separately), never * and never *.com. Measured against a real page
load of one vendor tag, that turned a four-host list with one host no
static scan could find into a single entry.
connect-src before it leaves the
browser, with no error visible to the script: a fetch
rejects like a network failure, an image simply never loads. The sandbox
document listens for the browser's own violation report and forwards it,
so it counts as blocked in the script health view, and the script.blocked event names the host: detail: { reason: "csp", directive, blockedUri }
— beside the existing reason: "consent".
Capped at five per frame. If a script silently never talks to your
endpoint, that is the first place to look.
Scripts shipped by an installed app
Everything above is a script you wrote. A marketplace app can also ship its own, declared in its manifest, and a merchant consents to them at install. They run in the same sandbox, on the same event bus, and appear in Settings → Scripts as a read-only inventory — which doubles as your PCI DSS 6.4.3 script inventory.
Declaring them
{
"name": "Acme Analytics",
"slug": "acme-analytics",
"scopes": ["orders:read"],
"scripts": [
{
"key": "pixel",
"name": "Conversion pixel",
"tier": "sandbox",
"surfaces": ["storefront", "checkout", "quote"],
"consentCategory": "analytics",
"protectedData": "hashed",
"externalDomains": ["region1.google-analytics.com"],
"source": "./scripts/pixel.js"
},
{
"key": "onsite",
"name": "Onsite forms",
"tier": "dom", // storefront only, staff-reviewed
"surfaces": ["storefront"],
"consentCategory": "marketing",
"externalDomains": ["static.klaviyo.com"],
"source": "./scripts/onsite.js"
}
]
} externalDomains are matched exactly, not by parent domain: the runtime CSP is built
as connect-src https://<host> per
entry, which does not match a subdomain. Declare every host you actually
call, or *.vendor.com for a vendor SDK
whose host set you do not control (see External domains). They
are not domain-verified — an app provably cannot own googletagmanager.com — so they are gated
by review instead.
The two tiers
| Tier | Where it runs | Available on |
|---|---|---|
sandbox | Its own null-origin iframe. No page DOM, no cookies of its own, network limited to declared hosts. | Any surface, including checkout. |
dom | Injected as a real <script> onto the merchant's page, with full access. This is how an unmodified vendor tag works. | Storefront only, and only from an extension version Throttle staff have reviewed. Never checkout. |
It gets the real document, the real cookie jar and the network. That is the point — it exists so a vendor tag can work — and it is why it is confined to the merchant's own storefront, refused on checkout by a database constraint as well as by the resolver, and re-reviewed on every publish rather than only when it first appears.
Injection carries Subresource Integrity derived from the script's content hash, so a mismatched body is refused by the browser rather than executed. And withdrawing consent removes the tag but cannot undo what the script already did — that is inherent to the tier.
Automated review gates
Every script is parsed and walked as an AST — not regex-scanned, which
would fire on the word eval inside a
comment and miss window['doc'+'ument'].
A submission is rejected outright for source over 128KB, source that does
not parse, a URL literal on an undeclared host, a dom-tier script on a
non-storefront surface, or reaching for eval, new Function, parent, top, opener or importScripts. document and window are additionally forbidden for a
sandbox-tier script, where they are shadowed anyway — a dom-tier script
legitimately needs them.
A reviewer also sees soft flags for judgement rather than blocking: runtime-assembled URLs, computed member access and obfuscation heuristics. Static analysis says what code appears to do; a runtime harness fires synthetic events at the candidate and records every destination it actually reached, so the two can be compared.
What a script declares is frozen once it ships
A script's tier, surfaces, consentCategory, protectedData and externalDomains belong to the script key,
not to one version of it. Once any version carrying that key is published,
PATCHing one of those fields is refused
with script_published_capabilities_frozen.
Only name stays editable.
dom — and be
running unsandboxed code on every installed storefront without anything
being reviewed. To change what a script claims, publish it under a new
key: a new key is a new script, and a new script goes through review.
Consent on upgrade
A new version that adds a script key the merchant does not already run
requires them to re-consent — through the same mechanism as new scopes,
with acknowledgedNewScripts on the
upgrade call. A version that drops a script does not re-prompt:
the merchant ends up running less, which needs no permission, and
prompting anyway teaches merchants to click through consent dialogs.
Your controls over an app's scripts
Open the installed app and its Scripts tab. Every script that version ships is listed with what it declares — the surfaces it runs on, the consent category it needs, the buyer data it receives and the hosts it talks to — and two controls per script:
- On / off. Switch off one script without uninstalling the app. It stops on the next page load.
- Settings. A JSON object handed to the script at run time, which is how one app's script behaves differently for you than for another merchant (your GA4 measurement id, for instance). It reaches the buyer's browser, so never put a secret in it.
Uninstalling the app stops every script it ships at once, and a staff takedown does the same across every merchant immediately. Neither leaves anything behind to clean up.
Authoring from the CLI
throttle extensions scripts list <extensionId>
throttle extensions scripts push <extensionId> \
--version-id <draftVersionId> \
--key pixel --name "Conversion pixel" \
--source ./scripts/pixel.js \
--surfaces storefront,checkout \
--consent analytics --pii hashed \
--domains region1.google-analytics.com
throttle extensions scripts rm <extensionId> <scriptId> Building an app that ships scripts is documented in full — declaring them, shipping updates, review, and what happens to them across install, uninstall and takedown — in Scripts an Extension Ships .
Your own storefront
Hosted checkout mounts scripts for you. Your own storefront is not
Throttle's page, so nothing can mount there until you load it — @usethrottle/scripts is that loader. It
runs on your origin, fetches the scripts you have marked for the storefront surface, and mounts each one
in exactly the same opaque-origin sandbox hosted checkout uses. A script
still never receives a document.
One script tag
<script src="https://cdn.jsdelivr.net/npm/@usethrottle/scripts/dist/t.js"
data-publishable-key="pk_live_..."></script>
<script>
// Queued until the manifest loads, then flushed in order. Never dropped.
Throttle.publish('page.viewed', { path: location.pathname });
</script> Or from npm
import { createThrottleScripts } from '@usethrottle/scripts';
const throttle = await createThrottleScripts({
publishableKey: 'pk_live_...',
consent: { analytics: true },
});
throttle.publish('product.viewed', { sku: 'ABC-123', price: 4200 }); React
import {
ThrottleScriptsProvider,
useThrottleScripts,
} from '@usethrottle/scripts/react';
<ThrottleScriptsProvider publishableKey="pk_live_..." consent={{ analytics }}>
<YourStorefront />
</ThrottleScriptsProvider>;
// anywhere inside
const { publish } = useThrottleScripts();
publish('page.viewed', { path: location.pathname }); Both are required, and skipping either produces a silent dead loader rather than an error you would notice:
- Add your storefront origin to the application's allowed
origins. Without it the manifest is refused with
403 origin_not_allowed, and — the half people miss — the browser also refuses to frame the sandbox document, becauseframe-ancestorsis built from that same list. An application with no configured origins can only run scripts on hosted checkout. - Allow framing Throttle's sandbox origin in your own
CSP. The sandbox document is served by Throttle, so your
page's
frame-srcmust permit it.
Origin changes take effect within about a minute — the frame policy is cached for 60 seconds.
Events you publish
Throttle cannot see your pages, so these come from you. Same names, same
bus as the checkout.* events above — a
script does not care which side published an event.
| Event | When you publish it |
|---|---|
page.viewed | Any page view. Every pixel opens with one. |
product.viewed | A product detail page. |
collection.viewed | A category or collection listing. |
search.submitted | An on-site search. |
cart.* | The existing cart catalog — cart.item_added and the rest. Publish these if you manage the cart yourself. |
custom.* | Your own events — custom.quiz_completed, custom.size_guide_opened. Anything after the prefix is yours. |
checkout.* is refused here.
Those are emitted by Throttle's own checkout surfaces; publishing them
from a storefront would fabricate funnel data that the dashboard reports
as real. The call is dropped and a warning is logged. Reserving the one custom. namespace above is what lets
everything else stay closed.
Consent
Consent fails closed, exactly as on checkout: until you call setConsent, only essential scripts run. Widening consent
mounts the newly permitted scripts immediately — no page reload — and
because the event bus replays, a script that mounts late still receives
the page views you published before it existed. Narrowing consent tears
down scripts that are no longer permitted.
The manifest, directly
The loader wraps this; you rarely call it yourself. It is keyed by a publishable (pk_) key
holding storefront_scripts:read — the
key already encodes the application and environment, so test and live
resolve different sets with no extra parameter. Never put an sk_ key on a page: it can author
scripts, which means injecting code into every buyer's browser.
curl https://api.usethrottle.dev/api/v1/storefront/scripts \
-H "X-API-Key: pk_live_..." \
-H "Origin: https://shop.example.com"
Load outcomes on hosted checkout are reported against the checkout
session. A storefront page has no session, so nothing is reported to
Throttle and the dashboard's 24-hour counts stay empty for storefront-surface scripts. The loader
hands every outcome to an onOutcome
callback instead — send those wherever you already send front-end
errors.
Managing scripts
Scripts are managed per application and environment from Settings → Scripts in the dashboard. Each script has a per-script Enable toggle, and the page carries one environment-wide kill switch — an emergency stop that mounts zero scripts on any surface, regardless of each script's own enabled state, and takes effect on the very next page render (there is no cache in front of it).
Each script row also shows its last-24-hour load outcomes — loaded,
error, blocked, timeout — reported by buyers' own browsers. unavailable is distinct from
zero: if the health fetch itself fails, the dashboard shows the
literal word unavailable rather than 0 for every count, so a merchant never
mistakes "we could not check" for "nothing went wrong."
API
Six endpoints under /api/v1/application-scripts,
scoped to the application + environment your API key was minted for.
Mutating routes require applications:write;
reads require applications:read.
Create a script
curl -X POST https://api.usethrottle.dev/api/v1/application-scripts \
-H "X-API-Key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"key": "checkout-relay",
"name": "Checkout analytics relay",
"source": "register(async ({ browser, init }) => { fetch(\"https://analytics.example.com/collect\", { method: \"POST\", body: JSON.stringify({ url: init.context.location.href }) }); });",
"surfaces": ["checkout"],
"consentCategory": "analytics",
"externalDomains": ["analytics.example.com"]
}' List scripts
curl https://api.usethrottle.dev/api/v1/application-scripts \
-H "X-API-Key: sk_live_xxx"
Add ?include=source to get the source
back too — the default list omits it; this is a management surface, not
an editor.
Update a script
curl -X PATCH https://api.usethrottle.dev/api/v1/application-scripts/{id} \
-H "X-API-Key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"enabled": false}' Delete a script
curl -X DELETE https://api.usethrottle.dev/api/v1/application-scripts/{id} \
-H "X-API-Key: sk_live_xxx" Soft-deletes (204 No Content) — existing health/load-event history keeps resolving to a name.
Kill switch
curl -X PUT https://api.usethrottle.dev/api/v1/application-scripts/kill-switch \
-H "X-API-Key: sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{"disabled": true}' Health
curl https://api.usethrottle.dev/api/v1/application-scripts/health \
-H "X-API-Key: sk_live_xxx" Last-24h load outcome counts per script id — the same data the dashboard's Settings → Scripts page renders.
Limits
- 128KB per script (131072 bytes, measured as UTF-8 byte length) — a stored script should be a stub that loads or calls out to a vendor, not the vendor's whole library.
- 10 scripts per surface, per environment.
storefrontandquoteare accepted values today (the write API and the dashboard form both take them) but nothing resolves or mounts a script tagged for either surface yet — onlycheckoutis wired. - Sandbox tier only. There is no
tiercolumn on the underlying table at all — a merchant script cannot become a DOM-tier script (one that gets a real<script>tag on the host page) in this release, or ever, without a schema change.
What is not in this release
Named so a reader does not go hunting for something that is not there yet:
- Plaintext buyer PII. There is no grade that delivers it.
"hashed"is the most any script receives — SHA-256 of email and phone, hashed before it crosses into a frame. There used to be a third grade,"raw", which was accepted and stored and then delivered exactly what"none"does; it is now refused outright, because advertising a capability we deliberately would not honour was worse than not offering it. Plaintext delivery would need a new grade with a consent and data-protection story attached. checkout.payment_submitted/checkout.failedfor Net30. Both fire only from the Gr4vy card-payment widget (see the callout in the event catalog above) — a Net30 payment attempt does not produce either event.- Typed settings forms. Settings are edited as a JSON object, for both your own scripts and an installed app's. An app cannot declare a schema for its script's settings, so there is no field-by-field form and no validation beyond “must be a JSON object”.