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
workspaceKeyworks 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:
webEnvironment | Audience | Examples |
|---|---|---|
production | Production | app.acme.com, acme.com, www.acme.io |
preview | Beta | staging.acme.com, *.vercel.app, *.netlify.app, *.fly.dev, *.pages.dev, preview.*, dev.*, qa.*, *-staging.* |
localhost | Dev | localhost: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:
| Platform | releaseChannel | Audience |
|---|---|---|
| iOS | xcode-debug | Dev |
| iOS | testflight | Beta |
| iOS | app-store | Production |
| Android | xcode-debug (Gradle BuildConfig.DEBUG) | Dev |
| Android | play-internal | Beta |
| Android | play-production | Production |
| Android | sideloaded | Dev |
| Either | unknown | Production (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:
- SDK override. If you call
apex.init({ environment: "preview" })(or"production"/"localhost"), Apex uses that value and stampswebEnvironmentSource: "override". - Event URL. Apex parses the hostname from the event's own
urlfield. This is the most accurate signal because a single SDK can fire from multiple hostnames in one session. - Request Referer / Origin. Falls back to HTTP headers when the event has no URL.
- 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.comresolves 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
localhostso 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:
- 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. - Debug configuration —
#if DEBUG→xcode-debug. Same reasoning: if the app is built with-DDEBUGit's not a public build, regardless of how it was distributed. - StoreKit 2 receipt —
Bundle.main.appStoreReceiptURL?.lastPathComponent:sandboxReceipt→testflightreceipt→app-store
- 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:
- Gradle
BuildConfig.DEBUG→xcode-debug(yes, the channel name is shared across platforms for the Dev audience). PackageManager.getInstallSourceInfo()(API 30+) — the modern installer-attribution API:com.android.vending→play-productioncom.google.android.feedback→play-internal(closed/internal testing tracks)- Anything else →
sideloaded
- Fallback —
PackageManager.getInstallerPackageName()on pre-API-30 devices, same mapping. - 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.
The exemptions:
testMode: trueevents — 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 (prefixsbx-) you don't needtestMode— the sandbox workspace is the isolation primitive, and stackingtestMode: trueon top of a sandbox key actively breaks identity stitching and the Contacts pipeline. Prefer sandbox for QA; reservetestModefor 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:
- Set up your override at SDK init time, before the first event ships.
- If you discover misclassification, fix the override and verify the next event lands in the right bucket.
- 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
| Vendor | Auto TestFlight detection | Auto Play internal detection | Environment slicing | Override pattern |
|---|---|---|---|---|
| Apex | Yes (StoreKit 2 receipt) | Partial (closed-track via getInstallSourceInfo) + manual override | Automatic, one key | Single releaseChannel field at init |
| AppsFlyer | No — manual flag | No — manual flag | Manual | SDK test-mode flag |
| Adjust | No — environment flag | No — environment flag | Manual environment flag | environment: "sandbox" |
| Branch | No — partial via receipt | No | Separate keys | Separate test/live API keys |
| Firebase | No | No | Manual | setAnalyticsCollectionEnabled |
Web
| Vendor | Auto staging detection | Environment slicing | Override pattern |
|---|---|---|---|
| Apex | Yes (hostname matcher: Vercel, Netlify, Cloudflare, staging.*, etc.) | Automatic, one key | Optional environment at init |
| Segment | No | Separate sources | Separate write keys per source |
| PostHog | No | Separate projects | Separate API keys per environment |
| Mixpanel | No | Separate projects | Separate workspaces (dev / prod) |
| Amplitude | No | Separate projects | Separate workspaces (dev / prod) |
| GA4 | No | Separate data streams | Server-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
workspaceKeyfor 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
- Deploy the snippet to your staging URL (e.g.
staging.acme.comor a Vercel preview). - Fire any event on that URL — a pageview is enough.
- In the dashboard, open Settings → Workspace → Web environments. You should see a row labelled "Preview" appear within ~30 seconds.
- If you see your staging traffic landing under Production instead, the hostname doesn't match Apex's preview patterns. Pass
environment: "preview"toapex.init()to force the bucket.
Mobile
- Open your app on a real device with a real distributed build (TestFlight or App Store).
- Fire any event.
- In the dashboard, open Settings → Mobile apps → Build environments. You should see a row appear for your channel within ~30 seconds.
- If you don't, check:
- Is the SDK on the latest version? (Build Environments lights up
unknownrows 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.
- Is the SDK on the latest version? (Build Environments lights up
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.
Related
- Mobile Measurement Dashboard — the live view of your channels
- Settings → Workspace → Web environments — Web environment rollup
- Settings → Mobile apps — Build environments + My team device list
- Snippet installation —
apex.init()options includingenvironment - Pricing — the per-event price table (Beta and Dev are always $0, web and mobile)