Crossdeck Docs
Dashboard

Sandbox vs production

Environments 6 min read · Covers SDK, backend, dashboard, and all three rails

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

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:

ValueMeaningSet 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).

Why one project, not two?

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

  1. Public key must start with cd_pub_. Throws invalid_public_key otherwise. (Catches a secret key or a typo.)
  2. appId must be present. Throws missing_app_id if absent. (Catches the common "I copied the key but forgot the appId" mistake.)
  3. environment must equal exactly "production" or "sandbox". Throws invalid_environment for anything else, including undefined, "prod", "sand", "test", or any case variant.
  4. Key prefix and environment must agree. cd_pub_test_… with environment: "production" throws environment_mismatch. cd_pub_live_… with environment: "sandbox" throws the same. The check uses the prefix-to-env mapping in inferEnvFromKey (sdks/web/src/crossdeck.ts:1807-1811): cd_pub_test_"sandbox", cd_pub_live_"production". No other prefixes route.
Why ask for 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.

What happens if an SDK ships an event with a mismatched env claim?

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:

  1. URL query string?env=sandbox or ?env=production on the current page. Authoritative when present. A deep-linked URL from a teammate carrying ?env=sandbox lands you in sandbox on first paint. Writing the URL to localStorage the moment the URL is parsed makes the choice sticky.
  2. 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.
  3. "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:

Always check the env pill before reading a number.

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 patternWhat it covers
localhost, 127.0.0.1Standard local dev.
0.0.0.0Default bind address for webpack-dev-server / vite dev server (audit-found gap — used to leak before the fix).
::1, [::1]IPv6 loopback.
fe80::/10IPv6 link-local — covers e.g. Safari Web Inspector debugging an iPad over USB.
*.localmDNS / Bonjour names (mymac.local).
10.x.x.x / 192.168.x.x / 172.16-31.x.xRFC1918 private ranges — laptop on the office LAN.
The auto-shutoff only catches local hostnames.

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:

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.

Apple sandbox transactions land in Crossdeck's sandbox env.

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.

TouchpointWhat can go wrongDefence
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:

  1. Identify every QA tester deterministically the moment they sign in: cd.identify("qa:wes_iphone") (a stable prefix you control).
  2. Tag their session with a super-property: cd.register({ qa_tester: true }). Every event they fire from that point on carries the flag.
  3. Exclude qa_tester == true in 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:

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.

Why we don't ship a third env.

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

  1. Open the API keys page in your project. Copy the test key for the SDK platform you're integrating.
  2. Initialise the SDK locally with that key and environment: "sandbox". Fire a few track calls (cd.track("test_event", { sandbox_smoke: true })).
  3. 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.
  4. 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.
  5. Open the events explorer at Events explorer in Sandbox. Your test_event entries 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.