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 project" + "live project" separately. The same projectKey works on localhost, staging, TestFlight, and production. The system decides what's billable — 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, both free). localhost matches Xcode-debug (both Dev, both free). www matches App Store (both Production, both billable). 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 to be sure Apex doesn't bill it).
  • You're running CI smoke tests against a hosted preview and want to guarantee localhost billing.
  • 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({
  projectKey: "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({
  projectKey: "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, but we never charge for Dev or Beta events, on web or mobile. The full rule:

  • Dev — always free.
    • Mobile: Xcode-debug, Gradle-debug, sideloaded. Includes your team's local dev builds, the iOS Simulator, and any unsigned APK.
    • Web: localhost, 127.0.0.1, *.local, *.test. Includes every npm run dev you and your team have running.
  • Beta — always free.
    • Mobile: TestFlight, Play internal/closed. Includes every beta tester you onboard before launch.
    • Web: *.vercel.app, *.netlify.app, *.pages.dev, staging.*, preview.*, all the standard preview-domain patterns. Includes PR previews, staging deploys, and QA environments.
  • Production — billable.
    • Mobile: App Store, Play production.
    • Web: any hostname that doesn't match a preview or localhost pattern.
  • Unknown — billable. The SDK couldn't detect a channel (mobile) or the hostname didn't match a known preview pattern (web). We err on the side of billing rather than undercounting, but the dashboard surfaces unknown events with an SDK-upgrade or override prompt so you can fix the root cause.
  • 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 project key (prefix sbx-) you don't need testMode — the sandbox project 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.

The math is transparent: every API response includes an X-Apex-Billable-Events header with the breakdown, and two dashboard surfaces show per-bucket counts with a "Billable" or "Free" pill on each row:

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

No retroactive credits

If Apex bills events as Production and you later realize they were actually beta (e.g. you forgot to set the override flag on an Android internal-track build), we do not retroactively credit the workspace. The CRO-honest rationale: refunding billable events post-hoc would create a loophole where merchants could classify any build as "beta in hindsight" to dodge the bill.

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 BEFORE billable events accumulate.

How Apex compares to other vendors

Mobile

VendorAuto TestFlight detectionAuto Play internal detectionFree beta eventsOverride pattern
ApexYes (StoreKit 2 receipt)Partial (closed-track via getInstallSourceInfo) + manual overrideYesSingle releaseChannel field at init
AppsFlyerNo — manual flagNo — manual flagNo (charges per "media source")SDK test-mode flag
AdjustNo — environment flagNo — environment flagSandbox is free, production is paidenvironment: "sandbox"
BranchNo — partial via receiptNoTest events on dev key onlySeparate test/live API keys
FirebaseNoNoFree at every tier (Firebase model)setAnalyticsCollectionEnabled

Web

VendorAuto staging detectionFree preview/staging eventsOverride pattern
ApexYes (hostname matcher: Vercel, Netlify, Cloudflare, staging.*, etc.)YesOptional environment at init
SegmentNoNoSeparate write keys per source
PostHogNoNo (counts toward MTU on every project)Separate API keys per environment
MixpanelNoNoSeparate projects (dev / prod)
AmplitudeNoNoSeparate projects (dev / prod)
GA4NoFree at every tierServer-side data streams

The defining Apex difference: one project key works everywhere and the SDK plus ingest pipeline decides what's billable, 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.

"One key, one snippet" guarantee

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

Use the same projectKey for localhost, staging, preview deploys, TestFlight, beta tracks, and production. Apex auto-detects the environment and bills only on production events. No separate sandbox project, 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 projects) 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, marked Free.
  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({ projectKey, 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 project, rotating the
project key for staging, or wiring two SDKs. Apex's audience
model handles environment separation without separate keys.