Analytics

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.

This is Slice 1 — read what is not here yet
This page documents exactly what ships today: your own scripts, sandbox-tier only, on the hosted checkout page (/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.

SurfaceWhere it mountsWhat you have to do
checkoutHosted checkout — /c/<sessionId> and /s/<sessionId>Nothing. Mounted for you.
quoteThe 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.
storefrontYour own pages, on your own domainInstall @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

CanCannot
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

register() is a convenience, not a requirement

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 levelapi 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:

register() — proven shape
// 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.

browser.loadScript()
// 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) } });
  }
});
What loads is not reviewed, and will change

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

EventTriggerPayload (event.data)
checkout.startedThe checkout page mounts.{} — empty.
checkout.step_changedThe buyer moves between checkout steps.{ step } — one of 'cart' | 'address' | 'shipping' | 'billing' | 'payment'.
checkout.payment_submittedThe card payment form is submitted (Gr4vy embed — the widget used by both /c and /s for card payments).{} — empty.
checkout.completedPayment 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.failedThe 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.
checkout.payment_submitted / checkout.failed are Gr4vy-embed only
Both fire from 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:

A boot-time relay (also works today)
// 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,
    }),
  });
});
Never hand a vendor secret to the sandbox
A script's source is delivered from a public, unauthenticated URL — any buyer's browser can fetch it in plain text. Providers whose ingestion APIs take a secret (GA4 Measurement Protocol's 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.

An undeclared domain is reported as a blocked outcome
A network call to a domain not on the list is blocked by the sandbox's CSP 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

text
{
  "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

TierWhere it runsAvailable on
sandboxIts own null-origin iframe. No page DOM, no cookies of its own, network limited to declared hosts.Any surface, including checkout.
domInjected 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.
A dom-tier script is not contained

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.

Why this is a hard refusal and not a re-review
A merchant's running install reads these fields live. If they could change, an app could ship a plain sandbox pixel, get it approved, then widen it — or flip it to 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

text
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

text
<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

text
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

text
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 });
Two things to configure, or nothing runs

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, because frame-ancestors is 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-src must 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.

EventWhen you publish it
page.viewedAny page view. Every pixel opens with one.
product.viewedA product detail page.
collection.viewedA category or collection listing.
search.submittedAn 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.

text
curl https://api.usethrottle.dev/api/v1/storefront/scripts \
  -H "X-API-Key: pk_live_..." \
  -H "Origin: https://shop.example.com"
No storefront telemetry in this release

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

POST /api/v1/application-scripts
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

GET /api/v1/application-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

PATCH /api/v1/application-scripts/:id
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

DELETE /api/v1/application-scripts/:id
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

PUT /api/v1/application-scripts/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

GET /api/v1/application-scripts/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. storefront and quote are 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 — only checkout is wired.
  • Sandbox tier only. There is no tier column 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.failed for 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”.