Test vs Production — How Apex Sees Your Builds and Deploys

When you ship a product, the same Apex SDK runs across a fleet of different environments — Xcode on your laptop and your localhost:3000 dev server, TestFlight betas and Vercel preview deploys, the App Store production build and your live www domain. Apex auto-detects which environment each event came from and slices your dashboard accordingly, so a thousand TestFlight installs (or staging-deploy form submits) the day before launch never spike (or noise out) the metric you actually launch on.

This page explains how the detection works for both web and mobile, what environment buckets it produces, how that affects billing, and how to override the result when the OS signal (mobile) or the hostname (web) isn't conclusive.

One key, one snippet, every environment. Apex's mental model is that you should never have to maintain a "test workspace" + "live workspace" separately. The same workspaceKey works on localhost, staging, TestFlight, and production. The system classifies the environment — not the key you pasted.

The environment model

Apex collapses every event — web or mobile — into one of four environment buckets: Production, Beta, Dev, or Unclassified. The dashboard's build-filter pill operates on this single axis, so you don't have to think about platform-specific channel names when you just want "show me beta data."

Web

Every web event is stamped with webEnvironment, auto-detected at ingest from the request's hostname:

webEnvironmentEnvironmentExamples
productionProductionThe website already on your workspace (acme.com, www.acme.com)
previewBetaVendor preview platforms (*.vercel.app, *.netlify.app, *.fly.dev, *.pages.dev)
localhostDevlocalhost:3000, 127.0.0.1, *.local, *.test
unknownUnclassifiedAny other host (dev.company.com, qa.company.com)

webEnvironmentSource is auto when Apex derived the value from the hostname, or override when an npm SDK install passed one in at init({ environment }).

Mobile

Every mobile event is stamped with releaseChannel, auto-detected by the SDK from the OS:

PlatformreleaseChannelEnvironment
iOSxcode-debugDev
iOStestflightBeta
iOSapp-storeProduction
Androidxcode-debug (Gradle BuildConfig.DEBUG)Dev
Androidplay-internalBeta
Androidplay-productionProduction
AndroidsideloadedDev
EitherunknownUnclassified

releaseChannelSource is auto when the SDK detected the channel, override when your code passed one in at initialize(), or play-api when Apex reconciled it server-side via the Google Play Console API.

The cross-platform symmetry: a preview deploy on web is in the same environment as a TestFlight build on iOS (both Beta). localhost matches Xcode-debug (both Dev). www matches App Store (both Production). Flip the dashboard's filter pill once and you see "all my non-production traffic" across the entire stack.

How web detection works

Apex inspects each web event's source hostname in this order:

  1. Sites you named. Hosts you listed as Production or Beta in Set up Apex → One key for every environment land in that bucket. This works for snippet and SDK installs alike.
  2. SDK override. An npm SDK install can pass init({ environment: "preview" }) (or "production" / "localhost"). Apex uses that value and stamps webEnvironmentSource: "override". The snippet has no override; name the site instead.
  3. Event URL. Apex parses the hostname from the event's own url field. This is the most accurate signal because a single SDK can fire from multiple hostnames in one session.
  4. Request Referer / Origin. Falls back to HTTP headers when the event has no URL.
  5. Unclassified. If none of the above is conclusive, the event is Unclassified. We do not guess that staging.company.com is staging, and we do not guess that app.company.com is live.

The hostname matcher recognises vendor preview platforms (Vercel, Netlify, Cloudflare Pages, Fly, Render, Railway, Heroku, Amplify, ngrok, Gitpod, Codespaces). It does not guess merchant host conventions.

Staging on your own domain

If your staging site is dev.company.com, qa.company.com, or similar, name it as Beta in Set up Apex → One key for every environment. No code change, and it works with the snippet.

When to use the SDK override

Only npm SDK installs have it, and most never need it. Reach for it when the same host serves traffic that belongs in different buckets, such as CI smoke tests against a hosted preview that you want kept as localhost:

import { init } from "@apex-inc/sdk";

init({
  workspaceKey: "apx_live_...",
  environment: process.env.CI ? "localhost" : undefined,
});

How mobile detection works

iOS

The SDK walks a four-step waterfall, top to bottom:

  1. Simulator check — #if targetEnvironment(simulator) → xcode-debug. Catches the case where you're running on the iOS Simulator with a release configuration but no real device.
  2. Debug configuration — #if DEBUG → xcode-debug. Same reasoning: if the app is built with -DDEBUG it's not a public build, regardless of how it was distributed.
  3. StoreKit 2 receipt — Bundle.main.appStoreReceiptURL?.lastPathComponent:
    • sandboxReceipt → testflight
    • receipt → app-store
  4. No receipt — unknown. Apex counts these as Production for billing (we never undercount).

The waterfall is deliberately conservative on iOS — the receipt path is unforgeable, so once we observe it we trust it. Manual releaseChannel overrides from JavaScript are ignored on iOS because there's no scenario where the SDK is wrong and the operator is right (the receipt always settles it).

Android

Android doesn't have an equivalent of the iOS receipt URL. The SDK uses a different waterfall:

  1. Gradle BuildConfig.DEBUG → xcode-debug (yes, the channel name is shared across platforms for the Dev environment).
  2. PackageManager.getInstallSourceInfo() (API 30+) — the modern installer-attribution API:
    • com.android.vending → play-production
    • com.google.android.feedback → play-internal (closed/internal testing tracks)
    • Anything else → sideloaded
  3. Fallback — PackageManager.getInstallerPackageName() on pre-API-30 devices, same mapping.
  4. No installer → unknown.

Unlike iOS, manual overrides ARE honored on Android. The reason: Google's getInstallerPackageName() returns com.android.vending for both production builds and closed-test tracks; the distinction only surfaces in the Play Console. So if you're running a closed beta and want Apex to bucket those events as Beta instead of Production, pass releaseChannel: "play-internal" to Apex.initialize(). The SDK stamps it as releaseChannelSource: "override" so the dashboard can show you the source of truth.

Android Gradle override pattern

The cleanest way to wire the override is with a build-variant flag in app/build.gradle:

android {
    buildTypes {
        debug {
            buildConfigField "String", "APEX_CHANNEL", "\"xcode-debug\""
        }
        release {
            buildConfigField "String", "APEX_CHANNEL", "\"play-production\""
        }
    }
    flavorDimensions "audience"
    productFlavors {
        production { dimension "audience" }
        internal {
            dimension "audience"
            buildConfigField "String", "APEX_CHANNEL", "\"play-internal\""
        }
    }
}

Then in your JavaScript:

import { Apex } from "@apex-inc/capacitor-plugin";
import { ApexChannel } from "./build-config"; // generated from BuildConfig.APEX_CHANNEL

await Apex.initialize({
  workspaceKey: "wsk_...",
  releaseChannel: ApexChannel, // "xcode-debug" | "play-internal" | "play-production"
});

This gives you a single source of truth (build.gradle) and removes any guesswork about which channel a given APK belongs to.

What this means for billing

Apex's billing is event-based, and every ingested event counts toward the Events meter, in every environment — Dev, Beta, and Production alike. This is the industry-standard model (Amplitude, Mixpanel, and PostHog all bill every ingested event); the generous monthly-reset free tier is what keeps pre-launch testing effectively free in practice. The environment buckets drive dashboard slicing and per-environment transparency on the Usage page, not billability.

Environment does not change the price. Unclassified events bill too. testMode: true is a dry-run flag (send-to-me, practice payloads) — it is not a free Dev or Beta bucket, and it is not TestFlight. Use one workspace key. Do not create a sandbox workspace to avoid billing.

Bot and origin-mismatch traffic Apex classifies as untrusted is stored for diagnostics and is not metered.

The math is transparent: every API response includes an X-Apex-Billable-Events header with the breakdown, the Usage & billing page shows a per-environment breakdown on the Events meter (e.g. "1,058 Dev · 37 Production"), and two dashboard surfaces show per-bucket counts:

  • Web: Settings → Workspace → Web environments
  • Mobile: Settings → Mobile apps → Build environments

No retroactive credits

If events land in one bucket and you later realize they belonged in another (e.g. you forgot to set the override flag on an Android internal-track build), we do not retroactively reclassify the workspace's history. Reclassification-in-hindsight would make every report a moving target.

The right pattern is:

  1. Set up your override at SDK init time, before the first event ships.
  2. If you discover misclassification, fix the override and verify the next event lands in the right bucket.
  3. The fix takes effect for future events. Past events stay where they are.

The Build Environments settings card prominently shows unknown-channel volume to give merchants the signal to fix it early.

Naming a web site is the exception. Apex applies the names you set when it reads your data, so naming qa.company.com as Beta moves its past events to Beta as well as its future ones.

How Apex compares to other vendors

Mobile

VendorAuto TestFlight detectionAuto Play internal detectionEnvironment slicingOverride pattern
ApexYes (StoreKit 2 receipt)Partial (closed-track via getInstallSourceInfo) + manual overrideAutomatic, one keySingle releaseChannel field at init
AppsFlyerNo — manual flagNo — manual flagManualSDK test-mode flag
AdjustNo — environment flagNo — environment flagManual environment flagenvironment: "sandbox"
BranchNo — partial via receiptNoSeparate keysSeparate test/live API keys
FirebaseNoNoManualsetAnalyticsCollectionEnabled

Web

VendorAuto staging detectionEnvironment slicingOverride pattern
ApexTheir website, localhost, and vendor preview platforms. Other hosts stay Unclassified.Automatic, one keyOptional environment at init
SegmentNoSeparate sourcesSeparate write keys per source
PostHogNoSeparate projectsSeparate API keys per environment
MixpanelNoSeparate projectsSeparate workspaces (dev / prod)
AmplitudeNoSeparate projectsSeparate workspaces (dev / prod)
GA4NoSeparate data streamsServer-side data streams

Like every vendor above, Apex meters all ingested events. The defining Apex difference: one workspace key works everywhere and the SDK plus ingest pipeline classifies the environment automatically, not the key you pasted. You don't have to maintain a separate "sandbox" project, manually flip a debug flag, or wire two SDKs across staging and production — and your Usage page shows exactly how much volume each environment contributed.

"One key, one snippet" guarantee

You'll see this callout on the snippet page in your dashboard, too:

Use the same workspaceKey for localhost, staging, preview deploys, TestFlight, beta tracks, and production. Apex auto-detects the environment so your dashboards slice cleanly. No separate sandbox workspace, no test key vs live key, no environment flag to remember.

This is intentional. Every other commercial analytics or MMP vendor we benchmarked makes you provision two keys (or two workspaces) and we've watched merchants ship a production build pointed at the test key (and vice versa) more times than we can count. Apex's mental model: one key, one snippet, and the system handles the rest — across the entire stack.

Verifying your setup

Web

  1. Deploy the snippet to a Vercel or Netlify preview, or to a staging host you named as Beta.
  2. Fire any event on that URL — a pageview is enough.
  3. In the dashboard, open Settings → Workspace → Web environments. You should see a row labelled "Preview" appear within ~30 seconds.
  4. If the host is not a vendor preview platform and you have not named it, the traffic lands under Unclassified. Name it as Beta in Set up Apex → One key for every environment.

Mobile

  1. Open your app on a real device with a real distributed build (TestFlight or App Store).
  2. Fire any event.
  3. In the dashboard, open Settings → Mobile apps → Build environments. You should see a row appear for your channel within ~30 seconds.
  4. If you don't, check:
    • Is the SDK on the latest version? (Build Environments lights up unknown rows for stale SDKs.)
    • Did you pass an override that conflicts with the OS signal?
    • On iOS, did you build with -DDEBUG? Debug builds are Dev regardless of distribution mechanism.

Apex Skill recipe

If you're using Apex's coding-agent skill (via Claude Code, Cursor, or any MCP-compatible client), this recipe is in the shipped skills under apex-environments:

Recipe: detect-and-route-by-environment
When the merchant asks "how do I keep my staging traffic
separate?", "should I use a different key for preview deploys?",
"how do I test my snippet without polluting production?", or
"do I get charged for dev events?":

1. Confirm they're on the latest @apex/sdk (web) or
   @apex-inc/capacitor-plugin (mobile). Older SDKs predate
   environment / releaseChannel detection.
2. Point them at /docs/mobile/test-vs-production for the
   full detection rules (covers web + mobile).
3. Walk them through the right verification surface:
   - Web → Settings → Workspace → Web environments
   - Mobile → Settings → Mobile apps → Build environments
4. If hostname auto-detection misses their staging URL (e.g.
   custom domain that doesn't match the standard patterns),
   name it as Beta with set_workspace_environments. Do not
   suggest a code change for snippet installs.
5. If they're on Android and need to distinguish closed-test
   tracks, show the Gradle override pattern.

Do NOT recommend setting up a second workspace, rotating the
workspace key for staging, or wiring two SDKs. Apex's environment
model handles that separation without separate keys.