One Flutter App, Two Stores: What Shipping NIM Sports and NIM Soul Taught Us
Between July and August we shipped two of our own apps to both stores: NIM Sports, an attendance-and-fees app for coaches, and NIM Soul, an encrypted phone-and-contacts app. Both are Flutter. Both share one sign-in package and go out from one build pipeline. Sports was approved on its first App Store submission. Soul was rejected in July and again in August. Neither rejection was about the app's code; both were about what the reviewer could see and do in the first five minutes. This is the list I wish I had read before the first upload, with Apple's guideline numbers, because those are what the rejection email quotes.
Key takeaways
- Both of our App Store rejections were about the review experience, not the code: a demo login that did not work as written, and Android mentioned in iOS release notes.
- Provide a fixed test identity for OTP-only sign-in, write the credentials exactly as the field expects, and sign in with them yourself on the day you submit.
- Play version codes and iOS build numbers are permanent and monotonic; read the version from one file, and promote the tested bundle to production instead of rebuilding.
- Apple enforces the current SDK at upload, not at validation; build on the newest macOS and Xcode, and smoke-test the release build because shrinking can break plugins that use reflection.
- Budget the store launch as its own week of work: privacy pages, declarations, size, signing and the checklist above.
The two launches, in one table
The pipeline is the same for both apps: a push to the main branch builds Android and iOS, uploads the Android bundle to Play's internal testing track and the iOS build to TestFlight, and a separate, deliberate step promotes a tested build to production. Everything below happened inside that pipeline, which is worth saying because none of it was caused by a manual upload going wrong.
| App | Google Play | App Store |
|---|---|---|
| NIM Sports | Live 7 July 2026 after internal testing | Submitted 14 July, approved on the first review, live within ten days |
| NIM Soul | Live; several releases since | Rejected 22 July under 2.1, fixed with a metadata-only resubmit and approved; a later release rejected in August under 2.3.10 and 2.1 |
What happened, store by store
Guideline 2.1: the reviewer has to get in
Both our apps sign in with a one-time code sent by SMS and nothing else. A reviewer in California cannot receive an SMS to an Indian number, so without help the review ends at the login screen, and Apple files that under Guideline 2.1, app completeness. The fix is a test identity on the authentication service: a fixed ten-digit number with a fixed code, where no SMS is ever sent and the code always works. Sports had one from the first submission, and Sports passed.
Soul had one too, and was still rejected. The reason was our own wording. The credentials in the review notes gave the number with the country code in front of it. The sign-in screen already had the country code selected, the reviewer typed it again, and the field silently kept the first ten digits, so the code went to a different number and the reviewer saw an invalid-code error. We only understood it because Apple's rejection attached a screenshot showing the masked number the code had gone to. The fix was two things: rewrite the note to give exactly what the field expects, which is a metadata-only resubmit against the same binary, and change the field so it can never truncate silently again.
The August rejection under the same guideline was the other classic. The test identity had an expiry date on the authentication service, and it had lapsed. Nobody had signed in with it since the previous review. The rule we run now is dull and absolute: sign in with the demo account yourself, on the device, on the day you submit.
Write the credentials the way the field expects them
If the country code is pre-selected on the sign-in screen, leave it out of the review notes. Say in the note that no SMS is sent and the code is fixed. Then use those exact notes yourself before you tap Submit.
Guideline 2.3.10: Android does not exist inside Apple's building
Soul's August release notes said, in passing, that a feature now matched what the app already did on Android. That single clause is a rejection under Guideline 2.3.10: an iOS app's metadata, screenshots and text may not name or show another mobile platform. It does not matter that it was true, or helpful.
The lesson is that release notes are two documents, even when the release is one. Android's notes can mention iOS and vice versa only if you enjoy resubmitting. We also learned to read every field back from the store after writing it through the API, because during that same diagnosis a placeholder string briefly landed in the live What's New field while a script was being debugged. What you wrote and what the store holds are not the same thing until you have read it back.
Version codes and build numbers are forever
Google Play's version code is permanent and monotonic across every track. A code uploaded to internal testing can never be uploaded again anywhere, and the next one must be higher, forever. We learned this the expensive way when a pipeline run uploaded the Android bundle successfully and then failed on the iOS side: the number was gone even though nothing shipped. In our pipeline the iOS build number is the same number, so a dual-store run consumes one number whatever happens.
Two rules came out of that. The version lives in exactly one file, the Flutter project's manifest, and the pipeline reads it verbatim; it never invents a number from a run counter or a tag. And production is a promotion, never a rebuild: the bundle that testers used on the internal track is reassigned to the production track through Play's API, so what goes to the public is byte-for-byte the build that was tested. A production step that rebuilds cannot work anyway, because the code is already used.
The build that passed validation and failed upload
Apple requires apps to be built with the current SDK, and it enforces that at upload. Our first iOS pipeline built on the previous macOS image with the previous Xcode, and the upload was refused with a message that the build had to use the iOS 26 SDK or later. The same build had passed Apple's own validation command a minute earlier, which printed a success line. Validation is not a gate. Only the upload is.
Signing had its own week of lessons. The API key we use in the pipeline is deliberately scoped, and a scoped key cannot use Apple's cloud signing, so the pipeline signs with a certificate and profile it holds itself. One certificate per app, stored with the password in a place you will find again: we lost one and had to issue a new certificate. And when Apple updates its developer agreement, every signing operation on the account stops until the account holder accepts it, which will happen on the day you least want it to.
Bugs that only exist in the release build
Android's release build runs code shrinking that a debug build does not. Shrinking strips the type metadata that some plugins read by reflection at runtime. In Soul, the incoming-call screen simply never appeared in the release build, while every debug build rang normally, because the plugin that reads the call's data could not deserialise it and failed silently. The tell-tale in the log is obfuscated class names, two letters and a dot, next to an error about a type token.
We keep shrinking off before launch; correctness matters more than a few megabytes. If you turn it on, add keep rules for every reflective plugin and re-test each of them on a release build. More generally, the smoke test before a store submission runs on the release build, not the one you have been developing on, because there is a whole class of failures the debug build cannot show you.
Size, privacy, and the pages you must already have
Soul's real-time calling library shipped roughly 81 MB of native code for every CPU architecture, including emulator-only ones no phone uses, and bundled extensions the app never enables. Dropping the emulator architectures and the unused extensions took tens of megabytes off each architecture, and shipping as an app bundle means the store delivers only the architecture the phone needs. Check this before your first upload; a store listing is a bad place to discover a 300 MB download.
Both stores also expect things that are not code. A privacy policy at a stable public address for each app; ours live on this site, for example the Sports policy. A completed privacy declaration that matches what the app actually collects. And one caution from our own audit: a privacy promise has to be enforced on each platform separately. Excluding data from Android's backups does nothing on iOS, and a policy that says the data never leaves the phone has to be true on both.
The checklist before you tap Submit
Everything above compresses into a short list that we now run, in order, before every submission. It takes twenty minutes. The two rejections it would have prevented cost us about three weeks.
- Sign in with the demo account yourself, today, on a phone, using the exact text in the review notes.
- Read the iOS notes and screenshots for the words Android or Play, and the Android notes for the words iPhone or App Store.
- Confirm the version code is higher than every code ever uploaded to any track, including failed runs.
- Build on the pipeline's newest macOS image with the newest Xcode; treat validation as a hint, not a pass.
- Smoke-test the release build: sign-in, the core flow, any call or notification path, and anything that reads types by reflection.
- Check the privacy policy URL returns the page, and that the privacy declaration still matches the app.
- Remember that automatic release on approval means this is the last gate. There is no second chance to stop it.
Neither rejection was about the code. Both were about what the reviewer could see and do in the first five minutes.
What this means if you are planning a launch
Cross-platform did what it promised: one codebase, two stores, feature parity on the day we shipped, which is the case we made in cross-platform versus native. The store process is the part nobody budgets for, and it is where the calendar slips. When we quote a mobile app now, the store launch is a named piece of work with its own week, not a line at the end that says publish. If you want the rest of the numbers, what a mobile app costs in 2026 has them.
Frequently asked questions
Why was my app rejected under App Store Guideline 2.1?
Guideline 2.1 is app completeness: the reviewer could not use the app. The most common cause for apps with phone or email sign-in is that the reviewer could not log in, either because no demo account was provided, or because the credentials in the review notes did not work exactly as written. Provide a test identity that needs no SMS or email, write the credentials exactly as the sign-in screen expects them, and sign in with them yourself on the day you submit.
What is App Store Guideline 2.3.10?
Guideline 2.3.10 requires an iOS app and its metadata to be about the iOS experience only. Naming or showing another mobile platform or app store in the description, release notes or screenshots is a rejection. Keep Android out of iOS release notes even when the release is identical.
Can I reuse a Google Play version code after a failed upload?
No. A version code is permanent across every track once Play has accepted it, including internal testing, and the next upload must use a higher code. If a pipeline run uploaded the bundle and then failed elsewhere, the code is spent. Read the version from one file, bump it before each upload, and promote the tested bundle to production rather than rebuilding.
Does an OTP-only app need a demo account for App Review?
Yes. The reviewer cannot receive your SMS, so provide a test phone number on your authentication service with a fixed code and no SMS sent. State in the review notes that no message is sent and the code is fixed, and give the number in the format the field expects.
How long did App Store review take?
For NIM Sports, the first submission on 14 July 2026 was approved on the first review and the app was live within ten days. Apple's own guidance is that most reviews complete within 48 hours; a rejection and resubmission restarts that clock.
Should the same build go to both stores?
The same source, yes, and in our pipeline the same build number, so one bump covers both. But the store metadata is two documents: release notes, screenshots and descriptions are written separately for each store, and neither should mention the other platform.
What is the difference between Play's internal testing track and production?
Internal testing is a private track for up to a small group of testers with no review delay; production is the public listing. The correct flow is to upload every build to internal testing first, test it, then promote that exact bundle to production through the Play Console or API. The version code is consumed the moment it reaches any track.
Have a project in mind?
We design, build, and ship software end-to-end — with a fixed, written quote after a free scoping call.
