Sandbox vs production
Every project carries two parallel worlds — sandbox and production — sharing one set of objects (apps, products, entitlements) but storing their own customers, events, purchases, and revenue. Which world an event lands in is decided by the publishable key prefix at the SDK boundary; the rest of the platform follows.
TL;DR
- Two prefixes carry the routing:
cd_pub_test_…→ sandbox,cd_pub_live_…→ production. The backend reads which one matched and routes accordingly — the SDK's declaredenvironmentstring is a confirmation check, not the source of truth. - Apps hold both keys at once. Every app in your project is provisioned with both
publicKeyTestandpublicKeyLive(backend/src/api/v1-auth.ts:202-204) so debug builds and release builds can each pick the right key without you minting separate apps. - The SDK validates both values up-front. If
environmentdisagrees with the key's prefix,Crossdeck.initthrowsenvironment_mismatchat boot (sdks/web/src/crossdeck.ts:223). You catch this in development, not in production. - Localhost is a third zone. The SDK detects local-dev hostnames and shuts off network calls entirely, even with a live key. Your dashboard stays clean while you iterate.
- The dashboard is sticky. Once you flip the chrome to sandbox, every page you load stays in sandbox until you flip back. Shared URLs carry the env in
?env=.
The model
One project; two environments per app. Object types that describe your business — apps, products, entitlements, rails, members, alert rules — exist once at the project level. Object types that record activity — customers, events, purchases, subscriptions, errors, MRR snapshots — exist twice, partitioned by an env field that takes one of two literal values:
| Value | Meaning | Set when |
|---|---|---|
"production" | Real customers, real money, real analytics that drive your business decisions. | The SDK was initialised with a cd_pub_live_… publishable key. |
"sandbox" | Test data — manual probes, CI smoke tests, App Store / Play Store / Stripe test-mode traffic. Never visible in the production dashboard. | The SDK was initialised with a cd_pub_test_… publishable key. |
The two environments share zero rows. A query against your production MRR feed will never return a sandbox subscription. A sandbox events explorer will never show production page views. That isolation is enforced at the query layer, on every read — not as a privacy boundary but as a correctness one (a Stripe-grade dashboard reads from one world at a time).
Stripe accounts have a single test/live toggle on the same workspace; Crossdeck mirrors that. The alternative — two separate Crossdeck projects, one for prod and one for testing — means duplicating every product, every entitlement, every rail connection, and every dashboard configuration. Twice the maintenance, two surfaces to drift out of sync. The dual-env-per-app model is the founder's call to keep the project as the single configuration boundary.
SDK side · key + environment
Every SDK takes two values that together identify the destination world: the appId (which app inside the project) and the publicKey (which env inside that app). The environment argument is a third value the SDK asks for explicitly, on purpose — see below.
The init validation chain
The Web SDK runs three validations on every Crossdeck.init({...}) call, in order (sdks/web/src/crossdeck.ts:200-230):
-
Public key must start with
cd_pub_. Throwsinvalid_public_keyotherwise. (Catches a secret key or a typo.) -
appIdmust be present. Throwsmissing_app_idif absent. (Catches the common "I copied the key but forgot the appId" mistake.) -
environmentmust equal exactly"production"or"sandbox". Throwsinvalid_environmentfor anything else, includingundefined,"prod","sand","test", or any case variant. -
Key prefix and environment must agree.
cd_pub_test_…withenvironment: "production"throwsenvironment_mismatch.cd_pub_live_…withenvironment: "sandbox"throws the same. The check uses the prefix-to-env mapping ininferEnvFromKey(sdks/web/src/crossdeck.ts:1807-1811):cd_pub_test_→"sandbox",cd_pub_live_→"production". No other prefixes route.
environment if the key carries it?
Belt-and-braces. A live key pasted by mistake into a debug build is one of the easier ways for a developer to accidentally pollute production analytics with their own test events. Requiring both — and erroring loudly when they disagree — forces the developer to declare intent at the call site, where it's easy to swap in lock-step with a BuildConfig.DEBUG / process.env.NODE_ENV branch. Stripe's stripe.api_key doesn't carry an explicit env declaration; Crossdeck's init({ environment }) does. The cost is one extra line of code; the saved class of errors is "I shipped a build where the env flag and the key were out of sync."
The pattern every quickstart ships with
Mirror the env flip at every layer in your build's branch on DEBUG:
// Web
Crossdeck.init({
appId: "app_web_xxx",
publicKey: process.env.NODE_ENV === "production"
? "cd_pub_live_…"
: "cd_pub_test_…",
environment: process.env.NODE_ENV === "production"
? "production"
: "sandbox",
});
// Android
val publicKey = if (BuildConfig.DEBUG) "cd_pub_test_…" else "cd_pub_live_…"
val environment = if (BuildConfig.DEBUG) Environment.SANDBOX else Environment.PRODUCTION
// iOS / Swift
let publicKey: String = {
#if DEBUG
return "cd_pub_test_…"
#else
return "cd_pub_live_…"
#endif
}()
let environment: CrossdeckEnvironment = {
#if DEBUG
return .sandbox
#else
return .production
#endif
}()
Same expression on both lines, so they can't drift. The SDK will catch a divergence at init either way, but you'd rather catch it at the compiler than at runtime.
Backend side · authoritative routing
The SDK is one piece of evidence about which env an event belongs to. The backend re-derives it from the key that authenticated the request, and the backend's answer wins.
When a request lands at /v1/events, the auth handler at resolveAppKey (backend/src/api/v1-auth.ts:234-251) looks up the publishable key against every app in the project. Each app stores both a test and a live key:
// backend/src/api/v1-auth.ts:234-238
let matched: "test" | "live" | "legacy" | null = null;
if (app.publicKeyLive === key) matched = "live";
else if (app.publicKeyTest === key) matched = "test";
else if (app.publicKey === key) matched = "legacy";
And then derives the env from which field matched, not from the SDK's claim:
// backend/src/api/v1-auth.ts:244-251
const env: Environment =
matched === "live"
? "production"
: matched === "test"
? "sandbox"
: app.environment === "production"
? "production"
: "sandbox";
The "legacy" branch handles pre-dual-key apps from before this provisioning model shipped — those apps store a single publicKey field and a separate environment field. Every app minted after that migration carries both publicKeyTest and publicKeyLive; the legacy branch is a safety net for older customers, not the happy path.
It can't — the SDK throws at init. But hypothetically, if a custom client posted directly to /v1/events with X-Crossdeck-Env: production and a cd_pub_test_… key, the backend would still write the event under env: "sandbox". The key is the source of truth on the server.
Dashboard side · env switcher
The dashboard chrome carries an env pill in the workspace switcher area — "Live · Production" in calm white when you're in production, "Sandbox" with an amber accent on body and chrome when you've flipped to sandbox. Every page reads currentEnv() (dashboard/_env.js:40-69) at mount and re-renders when it changes.
Resolution order
The dashboard answers "what env am I in?" in three tiers:
- URL query string —
?env=sandboxor?env=productionon the current page. Authoritative when present. A deep-linked URL from a teammate carrying?env=sandboxlands you in sandbox on first paint. Writing the URL tolocalStoragethe moment the URL is parsed makes the choice sticky. localStorage["crossdeck.env"]— set on every env switch (URL update and button click both write here). Subsequent page navigations without an explicit?env=inherit this value."production"default — confidence-first. A fresh browser session lands in Live. Sandbox is a tool you reach for deliberately, not a place you stumble into.
Switching envs
Programmatically: setEnv("sandbox") from /dashboard/_env.js. The function (dashboard/_env.js:79-100) does four things in a single tick:
- Writes
localStorage["crossdeck.env"]so a page reload sticks. - Updates the URL with
replaceState— sharing or copying the URL carries the env, but the back button doesn't rewind the env flip. - Sets
document.body.dataset.env— the amber chrome CSS hangs off this attribute and applies synchronously. - Emits a
crossdeck:envChangedCustomEvent so mounted pages can re-fetch their data without a full reload.
Sandbox stickiness is a feature — you can keep working in sandbox across pages without re-flipping — but it's also a way to read a number as production when it's actually sandbox. The amber chrome (background tint + pill colour) is the visual proof. If the chrome is calm, you're on Live; if it's amber, you're in Sandbox. The same data points will tell different stories.
The localhost auto-shutoff
The Web SDK detects local-dev hostnames at boot and goes fully local — every method (track, identify, isEntitled, captureError) still returns a sensible value, but no network calls fire and nothing lands in the dashboard. Implemented in isLocalHostname() (sdks/web/src/crossdeck.ts:1834-1865).
What counts as local
| Hostname pattern | What it covers |
|---|---|
localhost, 127.0.0.1 | Standard local dev. |
0.0.0.0 | Default bind address for webpack-dev-server / vite dev server (audit-found gap — used to leak before the fix). |
::1, [::1] | IPv6 loopback. |
fe80::/10 | IPv6 link-local — covers e.g. Safari Web Inspector debugging an iPad over USB. |
*.local | mDNS / Bonjour names (mymac.local). |
10.x.x.x / 192.168.x.x / 172.16-31.x.x | RFC1918 private ranges — laptop on the office LAN. |
If you spin your dev environment up at a real domain — dev.acme.com, an ngrok tunnel, a Vercel preview at acme-pr-42.vercel.app, a Cloudflare tunnel, your iPhone debugging your laptop over the WiFi at 192.168.1.42 (caught by RFC1918) but exposed externally via iphone-debug.ngrok-free.app — and you've got a cd_pub_live_… key in the bundle, you will pollute production analytics with your own dev traffic. Use a test key for any deployable preview environment, not just for localhost.
The escape hatch window.__CROSSDECK_FORCE_LIVE__ = true bypasses the local-dev guard and is reserved for E2E tests (Playwright on 127.0.0.1). It is not documented in SDK_TRUTH because real consumers should never set it.
Local-dev mode is JS-only
The iOS and Android SDKs don't ship the same hostname check — there's no equivalent to window.location.hostname in a native binary. Mobile relies entirely on the key-prefix-and-env-flag pattern: a debug build with a test key, a release build with a live key, both built into the binary via BuildConfig.DEBUG / #if DEBUG. You are responsible for swapping both fields together.
Payment rails
Each rail has its own concept of test vs live, and each connects to Crossdeck's env model differently. The rule across all three: a rail's test/sandbox traffic always lands in Crossdeck's sandbox env, and a rail's live traffic always lands in production — automatically. You don't pick.
Stripe
Stripe Connect distinguishes test mode and live mode per account inside the same Stripe workspace. When a customer OAuths their Stripe account into Crossdeck, that single OAuth grants access to both modes; the platform webhook handler reads charge.livemode === true on every event (backend/src/webhooks/stripe-platform.ts:732) and routes:
livemode: true→ Crossdeckenv: "production"livemode: false→ Crossdeckenv: "sandbox"
One Stripe OAuth covers both worlds. You don't need separate sandbox vs production Stripe connections.
Apple App Store
Apple sends App Store Server Notifications V2 for both production and sandbox to the same webhook URL — by design. Crossdeck's verifier tries the production JWS-decode first; on failure or when the decoded payload claims environment: "Sandbox", it re-verifies with the sandbox verifier (backend/src/webhooks/apple.ts:110-139).
You don't configure a separate sandbox webhook URL with Apple. You configure one URL; Crossdeck disambiguates per notification.
When you run StoreKit's "Test in Sandbox" flow from Xcode and complete a fake purchase, that transaction lands in your Crossdeck sandbox dashboard, not your production one. Flip the env switcher to Sandbox to see it. This is correct — sandbox-in, sandbox-out — but it's a surprise the first time you can't find your test purchase in the production view.
Google Play
Google Play distinguishes via "license testing" users rather than separate keys: a Play Console "tester" account that purchases sees a test transaction. Real Play Store purchases are production. Crossdeck routes test transactions to sandbox and real transactions to production using the same env-from-rail-evidence rule.
Wiring Google Play's Real-time Developer Notifications is covered in Rail webhooks → Google Play RTDN.
Touchpoints that quietly break things
The class of bug "I think I'm in test mode but I'm not" is the failure pattern this doc exists to prevent. Each row below names a real way it happens.
| Touchpoint | What can go wrong | Defence |
|---|---|---|
| Live key in a deployable preview environment | Preview deploys (Vercel, Netlify branch previews, Render preview environments, ngrok tunnels) run from real hostnames. The localhost auto-shutoff doesn't catch them. Every preview deploy with a live key pollutes prod analytics. | Wire your build pipeline so any non-production deploy gets the test key. Most CI providers carry a NODE_ENV, VERCEL_ENV, or similar — branch on it. |
| Live key in a TestFlight / Internal App Sharing release build | TestFlight binaries are release builds with the live key — your QA team's tap-throughs land in your production funnel. The hardest case in this whole doc, because TestFlight users are simultaneously "real users on real devices" and "people you can identify ahead of time." | See Where does TestFlight fit? below — we recommend one approach and tell you when to pick the alternative. |
| Test key in a release build going to the App Store / Play Store | Your live users hit the app, fire events, and none of it lands in your production dashboard — your sandbox dashboard would, but you're never looking there. Funnels look empty; alerts look quiet; the launch looks like a flop. | Inverse of the above — the BuildConfig.DEBUG / #if DEBUG branch must be tested against the actual release build (Release configuration in Xcode, ./gradlew assembleRelease) before submission. Open the heartbeat page on the API keys page after the first install; if your real key wasn't used, the heartbeat doesn't fire. |
environment and key prefix swapped together but in inverted code paths |
You wrote environment: DEBUG ? "production" : "sandbox" by mistake — same shape, wrong polarity. The SDK doesn't catch this because it's internally consistent (debug build → live key + production env, release build → test key + sandbox env). Wrong dashboard fills up. |
The SDK can't catch this; only your runbook can. Verify the heartbeat in both build configurations on every release. The Developers → Heartbeat page tells you which env the SDK reported, sourced from the key the backend matched. |
| Dashboard env stickiness across browser tabs | You opened one tab in sandbox an hour ago. You open a fresh tab today to check a number, expect Live, the URL doesn't carry ?env=, localStorage still says sandbox. The number you read is sandbox. |
Check the env pill in the workspace switcher before quoting any number from the dashboard. The amber chrome is the giveaway. |
| Stripe test-mode charges land in Crossdeck's sandbox env (Stripe-side surprise) | You ran a test charge from the Stripe dashboard ("Send test payment"). It doesn't show up in Crossdeck's production revenue. You think the rail is broken. | Flip the Crossdeck env switcher to Sandbox — the charge is there. livemode: false on the Stripe event is the routing signal, not a bug. |
| Apple StoreKit sandbox transactions land in Crossdeck's sandbox env | Same shape as Stripe — your Xcode "Test in Sandbox" purchase shows nothing in production. | Flip to Sandbox. The transaction is there, decoded payload's environment: "Sandbox" routed it. |
Where does TestFlight fit?
The platform ships two environments, not three — there is no built-in "staging." That leaves a real fork for TestFlight / Play Internal App Sharing: a release build, on a real device, run by people you know are not paying customers. Production semantics, sandbox intent.
Our recommendation: TestFlight is production
Use the live key, environment: .production, ship to TestFlight, accept that QA tap-throughs land in your production funnel. Then filter them out at the dashboard, not at the env boundary:
- Identify every QA tester deterministically the moment they sign in:
cd.identify("qa:wes_iphone")(a stable prefix you control). - Tag their session with a super-property:
cd.register({ qa_tester: true }). Every event they fire from that point on carries the flag. - Exclude
qa_tester == truein your funnel filters, MRR calculation views, and revenue alerts. The data stays honest — you can still see QA activity when you need to — but the headline numbers reflect real customers.
Why this beats inventing a third env: TestFlight binaries behave like the App Store version of your app. They run against your live Stripe rail, your live App Store Server Notifications, your live entitlement grants. Splitting them into a separate Crossdeck app means duplicating every rail connection and reconciling two parallel revenue streams that the underlying payment infrastructure considers one. The maintenance cost compounds.
When to split into a separate QA app instead
If you can answer yes to both:
- QA-driven events are more than ~5% of production event volume (every dashboard read pays the filter cost), and
- You operate a dedicated QA team that uses TestFlight daily as a workflow, not just for pre-release smoke tests.
Then create a second app at the project level — app_ios_qa_xxx — with its own publishable keys. Use a build-flavour or scheme split so TestFlight builds compile against the QA app's appId and live key, while App Store builds compile against the production app's. Both apps live in the same project, so products, entitlements, and the rail connections are shared. You get a clean QA dashboard at the cost of one extra app to keep in sync.
"Staging" would mean a third data partition with its own customers, events, purchases, revenue snapshots, and dashboard query layer. Stripe doesn't ship one either — they ship test mode + live mode and let customers spin up additional Stripe accounts for staging-shaped workflows. Crossdeck mirrors that: the env axis stays binary; the app axis is where you split shapes. The split is at the app boundary, not the env boundary.
Testing the flip end-to-end
To prove your env routing works without touching production:
- Open the API keys page in your project. Copy the test key for the SDK platform you're integrating.
- Initialise the SDK locally with that key and
environment: "sandbox". Fire a fewtrackcalls (cd.track("test_event", { sandbox_smoke: true })). - Open the Heartbeat page and flip the env switcher to Sandbox. The amber chrome confirms the flip. Heartbeat should show "Last seen: a few seconds ago" with your SDK version.
- Flip back to Live · Production. The heartbeat should now be silent for your local instance — the sandbox events are still there, but they don't show in this view.
- Open the events explorer at Events explorer in Sandbox. Your
test_evententries are visible. Switch to Live: gone.
This walk-through proves end-to-end that the partitioning works for your specific app + key combination. Repeat with a live key (or just deploy to production normally — every real customer's session is the same walk-through, automatically).
Data isolation
Sandbox and production share collections, partitioned by an env field on every row that records activity. projects/{projectId}/customers, projects/{projectId}/events, projects/{projectId}/purchases, projects/{projectId}/subscriptions, and the MRR snapshot stores all carry env: "production" | "sandbox". Every dashboard query filters on it; every backend ingest writes it.
The fields on each row that come from rail evidence (livemode from Stripe, environment from Apple, license-tester signal from Google) are projected onto the Crossdeck env field at write time, not derived on read. Once a row lands, its env is fixed. There is no "promote sandbox event to production" operation — the partition is structural.
This has one consequence worth knowing: the scrub-dogfood operation on Crossdeck's own project (backend/scripts/scrub-dogfood.cjs) wipes production data. Always operate on sandbox copies first. Customer projects have no equivalent operation exposed in the dashboard.
Related
- API keys & authentication — how publishable vs secret keys differ, and where to find both.
- Web SDK quickstart — the canonical init pattern.
- Web SDK error codes — full list of init-time errors, including
environment_mismatch,invalid_environment,invalid_public_key, andmissing_app_id. - Connect Stripe — how the Stripe rail routes test vs live charges.
- Connect App Store — how Apple notifications route by their decoded
environmentfield. - Rail webhooks — inbound verification per rail, including the Apple sandbox-vs-production verifier fallback.