# Sandbox vs production

> Source: https://cross-deck.com/docs/sandbox-vs-production/ · Crossdeck developer docs (generated from the published page).

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

- **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 declared `environment` string is a confirmation check, not the source of truth.

- **Apps hold both keys at once.** Every app in your project is provisioned with both `publicKeyTest` and `publicKeyLive` (`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 `environment` disagrees with the key's prefix, `Crossdeck.init` throws `environment_mismatch` at boot (sdks/web/src/crossdeck.ts:233). 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).

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

- **Public key must start with `cd_pub_`**. Throws `invalid_public_key` otherwise. (Catches a secret key or a typo.)

- **`appId` must be present**. Throws `missing_app_id` if absent. (Catches the common "I copied the key but forgot the appId" mistake.)

- **`environment` must equal exactly `"production"` or `"sandbox"`**. Throws `invalid_environment` for anything else, including `undefined`, `"prod"`, `"sand"`, `"test"`, or any case variant.

- **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:1937`): `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:

- **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.

- **`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:envChanged` CustomEvent so mounted pages can re-fetch their data without a full reload.

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

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:

- `livemode: true` → Crossdeck `env: "production"`

- `livemode: false` → Crossdeck `env: "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.

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](https://cross-deck.com/docs/rail-webhooks/#google).

## 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 == 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**:

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

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:

- Open the [API keys page](https://app.cross-deck.com/developers/api-keys) 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 few `track` calls (`cd.track("test_event", { sandbox_smoke: true })`).

- Open the [Heartbeat page](https://app.cross-deck.com/developers/heartbeat) 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](https://app.cross-deck.com/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.

## Related

- [API keys & authentication](https://cross-deck.com/docs/api-keys/) — how publishable vs secret keys differ, and where to find both.

- [Web SDK quickstart](https://cross-deck.com/docs/quickstart-web/) — the canonical init pattern.

- [Web SDK error codes](https://cross-deck.com/docs/web-sdk/error-codes/) — full list of init-time errors, including `environment_mismatch`, `invalid_environment`, `invalid_public_key`, and `missing_app_id`.

- [Connect Stripe](https://cross-deck.com/docs/connect-stripe/) — how the Stripe rail routes test vs live charges.

- [Connect App Store](https://cross-deck.com/docs/connect-apple/) — how Apple notifications route by their decoded `environment` field.

- [Rail webhooks](https://cross-deck.com/docs/rail-webhooks/) — inbound verification per rail, including the Apple sandbox-vs-production verifier fallback.
