Get a demo
Symptom → evidence

API requests work in TestFlight but fail in production

Answering: “api requests work in testflight but fail in production”

Evidence POST /auth GET /config GET /feed POST /order stalled → timeout 0s 30s
A network waterfall where three requests complete quickly and one stalls until it times out.

TestFlight and the App Store ship the same binary, so when API calls pass in TestFlight and fail in production the difference is the backend that one binary talks to at runtime: a base URL the App Store receipt flips to production, a pinned certificate that only mismatches the production host, or a key that only trips a quota under production load. You confirm which one by pointing a release build at production and watching the actual requests on the exact iOS version your users run.

Same binary, green in TestFlight, red in the App Store

Your CI is green. Testers ran the booking flow for a week and nobody complained. The App Store build ships and the map will not load, or “Confirm ride” spins forever, or your dashboards fill with failed requests. You cannot reproduce it: the debug build works, the TestFlight build still works, and your local release build works too. The failures cluster on production installs you cannot touch.

The runtime environment behind one archive

TestFlight distributes the exact archive the App Store later ships, so anything baked at build time (a react-native-config .env compiled into the Release scheme, a Config.plist) is identical in both. When behavior still differs, the binary is choosing its environment at runtime, and one of the few signals that differ between those installs is the App Store receipt, a known anti-pattern.

When a receipt is present, a TestFlight install carries a sandbox receipt (appStoreReceiptURL ends in sandboxReceipt) and an App Store install carries a production one. Code that branches on that, directly or through a native module, sends TestFlight to staging and App Store installs to production. The receipt is not guaranteed to exist, though: appStoreReceiptURL can be nil until a StoreKit refresh, so a naive check also lands on the wrong branch when the file is simply missing. If staging runs looser gateway auth, or serves an endpoint that was never promoted to production, TestFlight passes while production returns 404 or 403 on the same call.

Certificate pinning is the second cause, and it splits TestFlight from production only when that receipt branch routes them apart. If you pin (TrustKit, react-native-ssl-pinning, a URLSession delegate), the pinned key has to match the leaf or intermediate the production host actually presents. When the receipt branch keeps TestFlight on staging, a TestFlight tester never negotiates TLS with the production host at all, so pinning the wrong production chain, or rotating the production certificate out from under a pin baked into the archive, breaks only the production-receipt path. The request never reaches a status code. It dies in the handshake.

App Transport Security is the third. ATS enforces a client-side floor (TLS 1.2, forward secrecy, a modern cipher) and blocks any connection that falls below it. Info.plist exceptions loosen that floor for named domains, so an NSExceptionDomains entry written around the staging host lets a weaker staging endpoint through. Production is a different host with no exception, so it has to clear the full floor, and if the production edge sits below it (legacy TLS, no forward secrecy) ATS kills the connection only in production.

Quota is the fourth. A production API key or Maps key can be capped by daily quota or rate-limited per key. TestFlight’s handful of testers stays under the ceiling; production traffic across every install trips 429s or a quota-exceeded 403. On a map-heavy flow that surfaces as directions and geocoding calls failing while auth and profile stay fine.

Where the device dependency is real

Point a production-configured Release build at your local simulator and the config bugs surface fine: a wrong base URL 404s, a mispinned chain stalls in the handshake. ATS moves on major iOS releases, not point releases: Apple raised the TLS floor and tightened certificate rules at majors like iOS 11 and 15, so a build that clears the floor on the iOS 17 runtime your laptop carries can fail ATS on the iOS 18 your users run. A cloud device adds that exact model and OS on demand and a shareable record, instead of a debug session that dies with your simulator window. And you inspect the traffic without a proxy, because reading pinned requests in Charles means defeating the pin first, which deletes the exact failure you are chasing.

The network waterfall is where the config causes separate. A wrong base URL shows requests leaving for the staging host, or the production host answering 404 or 403 on an endpoint that works in staging. A pinning or ATS failure shows a request that stalls in the TLS handshake and returns no status and no body, and the logs carry the matching NSURLErrorDomain secure-connection error or the native SSL rejection. Step-level action traces tie each request to the tap that fired it, so “Confirm ride” maps to the exact POST that failed, and the video shows the spinner that never resolves.

Quota is the exception, the one cause you cannot reproduce on a single device. Once it is live a crash and error reporter (Sentry, Crashlytics) shows the 429s as breadcrumbs from production users. What a reporter cannot do is let you drive the flow and change one variable at a time. For that you need the Release build running on the OS your users are on.

Reproduce the production path on a cloud device

A Revyl cloud device is an iOS simulator, so it holds no App Store receipt, and a receipt-based check behaves however your code handles a missing receipt, not reliably like a TestFlight build. So do not lean on the receipt. Point the build at production explicitly instead: a Release variant carrying the production base URL and the production pinning set. Upload that artifact, boot the iOS build your failing users are on, and run the ubert booking flow: open the map, pick a ride option, confirm. Watch it live in the session viewer, or run it as an agent test and read the waterfall in the report. A 404 on the production host is your base URL or an unpromoted endpoint. A stall in the handshake is pinning or ATS. A clean run of 200s means the per-request path is sound and the failure is aggregate quota, which you confirm in production telemetry rather than on one device.

revyl auth login
revyl build --platform ios
# watch the flow live in the session viewer:
revyl device start --platform ios
# or run it as an agent test and read the report:
revyl test run book-a-ride --build-id <build-id>

Technical sources

Common questions

Why do my API calls work in TestFlight but fail in the App Store build?
TestFlight distributes the exact archive the App Store later ships, so the binary is the same. When behavior differs, the binary is choosing its environment at runtime: a base URL the App Store receipt flips from sandbox to production, a pinned certificate or ATS floor that only mismatches the production host, or an API key that only trips a quota under production load.
Can I reproduce a production-only API failure on a cloud device?
Partly, and you have to set it up correctly. A cloud device is an iOS simulator with no App Store receipt (the receipt file can be absent entirely until a StoreKit refresh), so a receipt-based environment check behaves however your code treats a missing receipt, not reliably like a TestFlight sandbox build. Do not lean on it: build a Release variant pointed at your production backend, upload that, and the run exercises the production host and certificate, so a wrong endpoint, a pinning mismatch, or an ATS block reproduces. An aggregate rate limit will not, because one device on one IP does not trip a production quota.
How do I tell a certificate pinning failure from a wrong base URL?
Read the network waterfall. A pinning failure (or an ATS block) stalls in the TLS handshake and never returns a status code or a response body, with a matching secure-connection error in the logs. A wrong base URL returns a 404 or 403 from the production host on an endpoint that works in staging, or shows requests leaving for the staging host instead.

Reproduce it on a cloud device

Choose a supported model and OS pair, run the flow, and read the full record of what happened.

Start free Read the docs