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
projectKeyworks 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:
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, 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:
- 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 to be sure Apex doesn't bill it). - You're running CI smoke tests against a hosted preview and want to guarantee
localhostbilling. - 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:
- 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({
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 everynpm run devyou 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: 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 project key (prefixsbx-) you don't needtestMode— the sandbox project 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.
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:
- 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 BEFORE billable events accumulate.
How Apex compares to other vendors
Mobile
| Vendor | Auto TestFlight detection | Auto Play internal detection | Free beta events | Override pattern |
|---|---|---|---|---|
| Apex | Yes (StoreKit 2 receipt) | Partial (closed-track via getInstallSourceInfo) + manual override | Yes | Single releaseChannel field at init |
| AppsFlyer | No — manual flag | No — manual flag | No (charges per "media source") | SDK test-mode flag |
| Adjust | No — environment flag | No — environment flag | Sandbox is free, production is paid | environment: "sandbox" |
| Branch | No — partial via receipt | No | Test events on dev key only | Separate test/live API keys |
| Firebase | No | No | Free at every tier (Firebase model) | setAnalyticsCollectionEnabled |
Web
| Vendor | Auto staging detection | Free preview/staging events | Override pattern |
|---|---|---|---|
| Apex | Yes (hostname matcher: Vercel, Netlify, Cloudflare, staging.*, etc.) | Yes | Optional environment at init |
| Segment | No | No | Separate write keys per source |
| PostHog | No | No (counts toward MTU on every project) | Separate API keys per environment |
| Mixpanel | No | No | Separate projects (dev / prod) |
| Amplitude | No | No | Separate projects (dev / prod) |
| GA4 | No | Free at every tier | Server-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
projectKeyfor 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
- 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, marked Free.
- 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({ 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.
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)