pex

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 audience 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 audience model

Apex collapses every event — web or mobile — into one of four audience buckets: Production, Beta, Dev, or Unknown. 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:

webEnvironmentAudienceExamples
productionProductionapp.acme.com, acme.com, www.acme.io
previewBetastaging.acme.com, *.vercel.app, *.netlify.app, *.fly.dev, *.pages.dev, preview.*, dev.*, qa.*, *-staging.*
localhostDevlocalhost:3000, 127.0.0.1, *.local, *.test

webEnvironmentSource is auto when Apex derived the value from the hostname, or override when your code passed one in at apex.init({ environment }).

Mobile

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

PlatformreleaseChannelAudience
iOSxcode-debugDev
iOStestflightBeta
iOSapp-storeProduction
Androidxcode-debug (Gradle BuildConfig.DEBUG)Dev
Androidplay-internalBeta
Androidplay-productionProduction
AndroidsideloadedDev
EitherunknownProduction (fail-safe)

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 audience bucket 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. SDK override. If you call apex.init({ environment: "preview" }) (or "production" / "localhost"), Apex uses that value and stamps webEnvironmentSource: "override".
  2. 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.
  3. Request Referer / Origin. Falls back to HTTP headers when the event has no URL.
  4. Production default. If none of the above is conclusive, the event is bucketed as Production (fail-closed — we never undercount real merchant traffic).

The hostname matcher recognises the major PaaS preview-domain conventions (Vercel, Netlify, Cloudflare Pages, Fly, Render, Railway, Heroku, Amplify, ngrok, Gitpod, Codespaces) plus the standard subdomain conventions (staging.*, preview.*, dev.*, test.*, qa.*, uat.*, *-staging.*, *-preview.*).

When to use the explicit override

Most merchants never need it. Reach for apex.init({ environment }) when:

  • Your staging deploy lives on a custom domain that looks like production (e.g. app-staging.acme.com resolves to your staging stack but you want it sliced as Beta in your dashboards).
  • You're running CI smoke tests against a hosted preview and want them bucketed as localhost so they stay out of your production headline numbers.
  • You've moved off the standard naming conventions and Apex's auto-detection isn't catching your hostname.
// Apex auto-detection is correct for 95% of merchants out of the box.
// Use the explicit override only when you need certainty.
apex.init({
  workspaceKey: "apx_live_...",
  environment: process.env.NODE_ENV === "production" ? "production" : "preview",
});

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 DEBUGxcode-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 receiptBundle.main.appStoreReceiptURL?.lastPathComponent:
    • sandboxReceipttestflight
    • receiptapp-store
  4. No receiptunknown. 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.DEBUGxcode-debug (yes, the channel name is shared across platforms for the Dev audience).
  2. PackageManager.getInstallSourceInfo() (API 30+) — the modern installer-attribution API:
    • com.android.vendingplay-production
    • com.google.android.feedbackplay-internal (closed/internal testing tracks)
    • Anything else → sideloaded
  3. FallbackPackageManager.getInstallerPackageName() on pre-API-30 devices, same mapping.
  4. No installerunknown.

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.

The exemptions:

  • testMode: true events — always free, regardless of channel/environment. Niche use case: QA/employee devices that are running your production build but whose traffic you want to keep out of CRM stitching, dashboards, and billing. If you have a sandbox workspace key (prefix sbx-) you don't need testMode — the sandbox workspace is the isolation primitive, and stacking testMode: true on top of a sandbox key actively breaks identity stitching and the Contacts pipeline. Prefer sandbox for QA; reserve testMode for the production-build-on-an-employee-device case.
  • Sandbox workspaces (prefix sbx-) — never bill.
  • Bot and origin-mismatch traffic — events Apex classifies as untrusted are stored for diagnostics but never 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.

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
ApexYes (hostname matcher: Vercel, Netlify, Cloudflare, staging.*, etc.)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 your staging URL (e.g. staging.acme.com or a Vercel preview).
  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 you see your staging traffic landing under Production instead, the hostname doesn't match Apex's preview patterns. Pass environment: "preview" to apex.init() to force the bucket.

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),
   show the explicit override:
     apex.init({ workspaceKey, environment: "preview" })
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 audience
model handles environment separation without separate keys.