Apple Rejected My App Eight Times. Here's the Playbook I Wish I Had.

By , Senior Full-Stack Engineer20 min read

Between June and August 2026 I shipped three apps to the App Store: SereneReader, the native iOS and Mac client for my RSS platform, with an auto-renewing subscription; Pulse, Google Analytics widgets for the Home Screen, the wrist, and the Mac menu bar; and SiteGrade, a website security configuration report card for iPhone, iPad, and Mac.

SereneReader's Mac 1.0 went first and collected eight findings across three rejection rounds in a single week. Pulse was rejected twice for things that had nothing to do with code. SiteGrade went through with far less drama, because by then I had written down every trap the first two hit and run the list before submitting.

That list became a guidebook that lives in each app's repo. This post is the public version. It is organized around what App Review flags, what the real cause usually is, and the shortest path to "Ready for Sale." The single most useful thing in it is the first section, because most submission pain comes from treating "Apple rejected it" as one problem.

Three Gates, Not One

There are three independent gates between your archive and the store. Each one fails differently and reports the failure somewhere different. The fixes do not overlap.

GateWho runs itHow it failsWhere the real error lives
Upload and processingAutomated (ITMS)The build shows "delivered" but never appears, or "Preparing build failed" with every CI step greenAn email from App Store Connect with an ITMS-xxxxx code. The web UI shows nothing useful.
Signing and exportXcode, Xcode Cloud, the developer portalexportArchive exits 70, "No profiles for ... were found"Portal state (App IDs, devices, capabilities), not your code
App ReviewA human reviewerA rejection citing a guideline numberOften the reviewer's environment (sandbox, region, account) rather than a bug

Before debugging anything, identify which gate rejected you. I lost most of an evening once chasing a "failed" build through CI logs when the answer was sitting in my inbox as ITMS-90242, a missing LSApplicationCategoryType in the Mac target's Info.plist. Nothing in Xcode Cloud was red. The build had uploaded fine. Apple's processing step rejected it afterward and told me by email.

The rest of this post is mostly about the third gate, because that is where the confusing rejections live. Two rules for the first two gates before moving on:

  • Missing LSApplicationCategoryType (ITMS-90242) and missing App Sandbox (ITMS-90296) are the two Mac delivery emails you will get once each. A networked Mac app also needs com.apple.security.network.client, or every request fails silently inside the sandbox. All three go in the project config once and never come back.
  • Export failures are almost always portal-side. A provisioned capability not enabled on the App ID, zero registered Macs on the team (the automatic development export needs one), or a new embedded target with no explicit App ID. Read the portal before reading your code.

The Sandbox-on-Production Trap

This is the most confusing subscription rejection Apple issues, and SereneReader got it on build 84, the third round.

The rejection reads "error message upon a successful in-app purchase." The reviewer buys the subscription, Apple's payment sheet succeeds, and the app then shows a server error. From the outside that looks like a broken purchase flow.

Here is what happens. App Review tests purchases in Apple's Sandbox environment. Your app posts the signed transaction to your production backend, which verifies the signature against Apple's root certificates and checks the environment field. Sandbox transactions are cryptographically valid, so if production accepts them, anyone with a sandbox Apple ID can mint a free subscription. A correctly built backend rejects them. The reviewer's purchase gets rejected by the same rule that protects you from fraud.

If you gate features off the tier your server reports (which you should, since local StoreKit state is not a source of truth), the reviewer sees the error and files 2.1(b).

My first defense was an allowlist: accept sandbox grants in production only for the demo account listed in App Review Information. That failed, because reviewers go off-script. The build 84 reviewer signed in with their own Apple ID and a Gmail account instead of the demo credentials, and the allowlist never fired.

The defense that works has no code in it:

  1. Put the sandbox check behind one environment flag on the server. Staging accepts sandbox grants. Production rejects them.
  2. Flip that flag to allow for the review window. Any verified Sandbox transaction now completes, regardless of which account the reviewer used. Real App Store purchases are production-environment and unaffected either way.
  3. Flip it back the moment the app is approved. The risk during the window is that TestFlight testers can grant themselves the paid tier. For a short, watched window that is acceptable.
  4. Reset the demo account to the free tier before every submission. A sandbox subscription granted on production never auto-expires, because sandbox renewal and expiry webhooks go to your sandbox notification URL. A demo account that bought last round still looks subscribed this round, and the reviewer never sees the paywall at all.

A few related rules that came out of building the subscription backend for SereneReader:

  • Finish transactions after the server acknowledges, never before. Unfinished transactions redeliver through Transaction.unfinished and Transaction.updates, which is your crash and offline recovery path. Finish first and a failed sync silently loses the entitlement.
  • Permanent rejections must still be finished. An expired purchase or an unverifiable payload will never succeed on retry. Leave it unfinished and it replays and re-fails at every launch forever. Keep transport errors and 5xx responses retryable; finish the 400s.
  • Re-buying an expired subscription can hand purchase() the original, already-expired transaction. Handle a 400 on the purchase payload by finishing the stale transaction and syncing currentEntitlements instead.
  • Send appAccountToken with every purchase, a UUID minted server-side per user before the purchase. It is how App Store Server Notifications map Apple's events to your accounts, and the server should reject a posted transaction whose token does not match the caller.

Pulse and SiteGrade have no backend. Their Pro unlocks are non-consumables verified on-device with StoreKit 2, so sandbox purchases complete in review without any of this. If you can design a paid app without a server, this entire section disappears.

Sign in with Apple Is Two Gates on macOS

SereneReader's very first Mac rejection, build 74, was guideline 4.0: Sign in with Apple "not displayed as an equivalent option." The button was smaller than the Google and GitHub buttons above it.

Two separate guidelines cover this, and passing one does not pass the other:

  • 4.8 says you must include it. If your app offers any third-party sign-in, you must also offer a privacy-preserving option, and Sign in with Apple is the safe answer. Email plus a one-time code does not qualify, because the user cannot keep their email private. Removing Apple sign-in "to simplify" while keeping Google is a guaranteed rejection.
  • 4.0 says it must render as an equal peer. The reviewer compares size, shape, and prominence against your other login buttons. Color is exempt. Apple's button cannot be tinted anyway, so keep the official black-on-light or white-on-dark style and put it first in the group.

On macOS the problem is that SwiftUI's SignInWithAppleButton ignores .frame() on macOS. It draws at a small intrinsic size, centered, and minHeight does not help. iOS stretches the SwiftUI button correctly. On the Mac, the fix was wrapping AppKit's ASAuthorizationAppleIDButton in an NSViewRepresentable whose sizeThatFits returns the proposed size, with the ASAuthorizationController owned by a coordinator. Four lines of layout in SwiftUI became a small AppKit bridge, and that was the only code change the round needed.

Pulse has no account system at all. Google OAuth there is authorization to the user's own Analytics data, which only Google can grant, so there is nothing Sign in with Apple could log into. If 4.8 ever comes up for an app like that, the reply is one sentence explaining the difference.

The Browser Sign-In Flag That Comes Back Every Round

On macOS, ASWebAuthenticationSession (and SwiftUI's WebAuthenticationSession wrapper) always opens the system default browser. There is no in-app variant like iOS's SFSafariViewController. This is the sanctioned flow. From the outside, though, a reviewer cannot tell it from a raw NSWorkspace.open, so both SereneReader and Pulse received the 4.0 flag "the app takes the user to the default browser to sign in."

A reply answers it. No code change. Apple's own next-step text says "if the app already uses this instance, reply and confirm." One paragraph confirming that web OAuth uses ASWebAuthenticationSession and that Sign in with Apple uses the native ASAuthorizationController and completes in-app is enough.

Two details matter here:

  • It is a boilerplate repeat flag. SereneReader got it on build 76 and again on build 84 after confirming. Paste the confirmation paragraph into every reply for the life of the app.
  • Do not cite the "Wants to Use ... to Sign In" system dialog as proof. With preferredBrowserSession: .ephemeral, macOS suppresses that prompt, so Google may show no dialog at all while GitHub shows its own authorize page. The shared code path is the proof.

The only code-level way to avoid the hand-off on macOS is to drop web OAuth there entirely. That is a product decision, and a big one.

When "Everything Is Broken," Check Your CDN First

SereneReader build 76 came back with two findings at once: 1.5 Safety, "the Support URL does not work," and 2.1(a), "none of the sign-in methods work."

Neither was an app bug. Cloudflare was challenging the reviewer's region and user agent. The support page served a bot challenge, and every API call from the app got the same treatment. Two rejections, one firewall rule.

The pattern to recognize: when a reviewer reports that the Support URL errors and that all sign-in methods fail in the same round, the odds are overwhelming that it is infrastructure. Fix the rule, reply asking for a retest, and touch no Swift.

This one is now the first item on my pre-submission checklist for every app: load the Support URL and the Marketing URL from a clean browser in another region before submitting. It caught nothing on Pulse or SiteGrade, which is the point.

Rejections You Fix in App Store Connect

A surprising share of rejections are answered by editing fields in App Store Connect. No binary, no new review of the build, usually a same-day turnaround. Five of them came up across the three apps.

Terms of Use link missing (3.1.2(c)). SereneReader's in-app paywall was already compliant: title, length, price, Restore Purchases, Terms and Privacy links beside the buy button. The finding was about the store listing. There is no dedicated "Terms of Use URL" field for the standard Apple EULA. Apple's own answer is a line in the App Description, such as Terms of Use: https://example.com/terms. A custom EULA goes in App Information under License Agreement instead. In-app links alone do not satisfy this, and it is the number one subscription rejection.

Apple product names in the subtitle (5.2.5). Pulse 1.1.0 shipped with the subtitle "Live visitors · Watch · Mac." Rejected. No Apple product name (Mac, iPhone, iPad, Watch, Vision Pro) may appear in the app name or subtitle in any locale, even as a plain platform list. Referential use in the description stays fine when the mark is an adjective on a generic noun ("your Mac menu bar"). The fix was editing the subtitle in every locale to generic surface nouns (wrist, desktop, menu bar) and resubmitting the existing build.

Automated login pre-screen (before any human reads your notes). Pulse 1.0.0 was rejected by a bot before any reviewer saw it. Apple's pre-screen detects login UI in the binary and, if the demo-account fields in App Review Information are empty, rejects on that alone. It does not read Review Notes. Pulse needs no login, because a one-tap Demo Mode exercises every feature with sample data, but the bot cannot know that. The answer was a Resolution Center reply confirming no login is required, the one-tap demo path, and a screenshot of the sign-in screen showing the "Try the demo" button. No new build.

Describing a feature the build does not have (2.3.1). This one I caught before submission, on SiteGrade. The drafted Mac description promised "Reports open in their own window." The app has exactly one WindowGroup. Reports open inside it. Marketing copy gets written from the design document, and the design is what got cut during the build. Before every submission, walk the description feature by feature against the app you built. The plan lies.

No way to reopen the main window (macOS, 4.0). Reviewers close the window, then look for it. If there is no File → New Window command that brings it back, that is a rejection. SereneReader got it on build 76. Add the menu item and handle the dock-icon reopen.

The Version Train Closes Behind You

Once a marketing version is approved on a platform, that version is closed for that platform forever. Every later upload at the same CFBundleShortVersionString is rejected at delivery with ITMS-90062 or ITMS-90186. It arrives as an email. Nothing turns red in CI. Nothing appears in Resolution Center. The binary never reaches review.

The check is per platform on a shared app record, so iOS 1.0 uploads kept working after SereneReader's macOS 1.0 shipped. The catch is that a train stays open across many builds while a version is in review, so the bump feels unnecessary right up until the approval that closes it.

I wrote this rule into Pulse's knowledge base after the first hit, and still missed it on 1.1.1 build 54 two days later. Bump the marketing version in the first commit of a release wave, before the work starts, because by the end of the wave you will have forgotten. A metadata-only resubmission is a patch bump. Companion targets such as a watch app move in lockstep, since their version has to match the host app anyway. And never reuse a released marketing version because "it was only a metadata change."

A related note: build numbers must be unique per version, and if CI stamps CFBundleVersion from the build number, do not also enable App Store Connect's auto-increment. Pick one source of truth or they fight.

Write the Review Notes Before the Reviewer Needs Them

By SiteGrade, the review notes were written before the first build was archived, because the first two apps had taught me what a reviewer needs to hear and what they will trip over without it.

Lead with how to review without an account. Pulse's notes now open with "NO LOGIN IS REQUIRED TO REVIEW THIS APP" and the one-tap demo path in the next sentence. That line exists because of the bot pre-screen above, and because a human reviewer who has to hunt for the demo path files a 2.1.

If sign-in is passwordless, set up a review bypass. App Store Connect's Sign-In Information has only Username and Password fields. If your app signs in with email plus a one-time code, the reviewer will fail to sign in and reject under 2.1 unless you pre-create a demo account on production and add a server-side fixed code scoped to that one email, gated by an environment variable. The fixed code must not count toward any per-IP or per-email rate limit, or a reviewer who retries gets a 429 and reports "sign-in is broken." Put the demo email in Username and the fixed code in Password, and use Review Notes to explain the flow without restating the credentials: "Sign-in is email on the first screen, then a 6-digit code on the second. The value provided as Password is that code." Apple's field help forbids raw credentials in the notes.

Pre-answer the flags you know are coming. SereneReader's and Pulse's notes both name ASWebAuthenticationSession up front, which answers the macOS browser flag before it is raised. SiteGrade's notes carry two paragraphs that head off a different worry entirely: the app declares an App Transport Security exception (NSAllowsArbitraryLoads), because testing whether a site redirects plain HTTP to HTTPS requires making a plain HTTP request. The notes say that, then state what the app does not do: no port scanning, no probing of anything the user didn't type, read-only GET requests to ports 443 and 80 equivalent to visiting the site in Safari. A reviewer reading "security" in an app name is going to wonder whether they are looking at an attack tool. Answer the question before it gets asked.

Say where the paywall lives. "Settings → Account shows the subscription, Restore Purchases, Terms, and Privacy." Reviewers change between rounds, and a reviewer who cannot find the purchase files 2.1 "could not locate the in-app purchase."

Describe only what is in the submitted build. Confirm the branch before naming a feature in the notes or a reply. Post-launch work sits on feature branches long before it ships.

Two pieces of paperwork belong in this section because they have multi-day latency and block everything downstream. The Paid Apps agreement must show Active before StoreKit will load products even in sandbox, and bank verification can take days. It is the single most common reason a paywall shows no products. The Small Business Program drops Apple's commission from 30% to 15% under the revenue threshold, but only takes effect the fiscal month after approval. Enroll before launch.

Writing the Reply

Assume the reviewer will not read your reply in full. That is not a guess. More than once I have written a careful reply, had the next rejection cite something I answered in paragraph three, and realized the reviewer stopped at paragraph one. Reviewers work through a queue, and a long reply reads as an argument before it reads as a fix.

So the reply has one job: put the fix where it cannot be missed. The shorter the reply, the more of it gets read.

  • Lead with the fix, one sentence per finding, in the order Apple listed them. If Apple listed one finding, the reply can be one sentence.
  • Cut everything that is not a fix or a confirmation. No background, no apology, no explanation of how the bug happened. Every extra sentence pushes the next fix further down the page.
  • Confirm the boilerplate items explicitly every time, even if you confirmed them last round. "Web sign-in uses ASWebAuthenticationSession. Sign in with Apple uses ASAuthorizationController and completes in-app."
  • Restate the sign-in path for the demo account in the reply as well as in Review Notes.
  • Do not narrate infrastructure. For the sandbox purchase fix, "we identified and fixed the issue that caused an error after a successful purchase; the purchase now completes" is the whole reply. Describing the mechanism invites follow-up questions, extends the cycle, and buries whatever comes after it.
  • Do not argue. If you believe a finding is wrong, state the fact once, cite the API, and ask for a retest.
  • When a fix needs a new binary, upload it, attach it to the version, and then reply. A reply on a version with no new build is treated as "no change."

Things That Look Wrong and Are Not

A short list of behaviors that look like bugs and are correct. Do not "fix" them.

  • macOS web OAuth opens the default browser. That is ASWebAuthenticationSession behaving normally.
  • TestFlight testers pointed at production cannot earn a real subscription. Sandbox grants are rejected on purpose.
  • The Apple sign-in button is a different color from your brand buttons. Apple requires its own style.
  • Sandbox subscriptions renew every few minutes and expire after a handful of renewals. That is the sandbox clock.
  • Products load as an empty list in a local Debug run without a .storekit file. Nothing sources them.
  • One App Store Connect build serves internal TestFlight, external TestFlight, and the public release. You do not rebuild per audience, and public release is always a manual step.

The Checklist I Run Now

This is the condensed version of what the three apps taught me. It runs before every submission, including the tenth.

  1. Marketing version has not already been approved on this platform. Build number is unique for this version.
  2. LSApplicationCategoryType, App Sandbox, and network.client are set on the Mac target. ITSAppUsesNonExemptEncryption is false if you only use standard HTTPS.
  3. The privacy manifest in the binary matches the App Privacy questionnaire. Subscriptions add Purchase History to both.
  4. Sign in with Apple is present and rendered as an equal peer on every platform, if any third-party login exists.
  5. Demo account exists on production and is reset to the free tier. Structured Sign-In fields are filled. Review Notes explain the flow and where the paywall lives.
  6. Support URL and Marketing URL load from a clean browser in another region.
  7. Privacy Policy URL is set. Terms of Use link is in the App Description.
  8. Every feature the description names exists in the build being submitted. No Apple product names in the name or subtitle in any locale.
  9. macOS has a File → New Window command. Screenshots are at an accepted exact size.
  10. For subscriptions: products are attached to the version submission, server notification URLs return 200 on a test notification, and the production sandbox-allow flag is flipped for the review window with a reminder set to flip it back.

Item ten has a detail that bit me once and deserves its own sentence. The first auto-renewable subscription must be submitted together with a new app version. Products sit in "Ready to Submit" until you attach them on the version page. Forget, and the app can be approved while the subscription is never reviewed, and the paywall shows nothing in production.

Eight findings in three rounds sounds like a lot, and at the time it felt like a lot. Looking back, one of them needed real code (the AppKit button), one needed a menu item, one needed a firewall rule, and the other five were answered with a reply or a field in App Store Connect. That ratio is the reason to look up the guideline number before opening Xcode.

Contact

Drop me a line. I read everything and reply within a day.

Required fields are marked “(required)”.