Your build passed device testing. Apple reviewed it on iPad Air 11-inch M3, hardware check passed. Then this arrived:
Guideline 2.1(b) - Performance - App Completeness
We discovered one or more bugs in your app when reviewed on iPad Air 11-inch M3 on iPadOS 18.4.1.
Specifically, the app includes references to premium plan but the associated In-App Purchase products have not been submitted for review alongside the app version.
The phrasing is misleading. "References to premium plan" sounds like a UI string bug. It isn't. "Have not been submitted for review" sounds like you forgot to click Submit. You didn't. The IAP is there — it just has 4 silent blocking conditions that Apple's UI never explains, and the rejection message doesn't identify which ones apply to you.
This is a postmortem from shipping NewDaysUntil 1.0 today. All 4 issues hit simultaneously, took an hour to trace, and are now resolved with IAP + Version both in WAITING_FOR_REVIEW. Here is every root cause and the exact fix for each.
What Guideline 2.1(b) Actually Means
App Completeness means: every feature reachable from the app's UI must be fully functional at review time. If your app shows a paywall, upgrade button, or "Premium" label, the IAP backing it must be:
- Created in App Store Connect
- Localized in at least en-US (plus any other locales your app supports)
- Have an App Review Screenshot attached
- Have territory availability set (non-empty)
- Be attached to the app version being submitted
- Submitted for review together with the version — not separately, not earlier, not later
If any single condition is false, the IAP is silently excluded from the submission and Apple will reject under 2.1(b). The rejection email won't tell you which condition failed.
The 4 Sub-Issues (All Hit on First Submission)
Issue 1: en-US Localization Was Missing
The IAP had localizations for ja (Japanese) and zh-Hans (Simplified Chinese). That's it. Apple requires en-US as the canonical base locale for every In-App Purchase, regardless of your app's primary language.
Diagnosis via the ASC API:
curl -s -H "Authorization: Bearer $ASC_TOKEN" \
"https://api.appstoreconnect.apple.com/v1/inAppPurchasesV2/{IAP_ID}/localizations" \
| jq '[.data[] | {locale: .attributes.locale, name: .attributes.name}]'
# Returns: [{"locale":"ja","name":"プレミアムプラン"},{"locale":"zh-Hans","name":"高级计划"}]
# Missing: en-US
Fix — POST the missing locale:
curl -X POST \
-H "Authorization: Bearer $ASC_TOKEN" \
-H "Content-Type: application/json" \
"https://api.appstoreconnect.apple.com/v1/inAppPurchaseLocalizations" \
-d '{
"data": {
"type": "inAppPurchaseLocalizations",
"attributes": {
"locale": "en-US",
"name": "Premium Plan",
"description": "Unlock all premium features"
},
"relationships": {
"inAppPurchaseV2": {
"data": {"type": "inAppPurchasesV2", "id": "{IAP_ID}"}
}
}
}
}'
Issue 2: App Review Screenshot Not Uploaded
Every IAP that goes through review needs a screenshot showing the purchase in context — typically your paywall or upgrade UI. Without it, Apple's internal tooling marks the IAP as incomplete. This is separate from the app's own metadata screenshots.
The ASC API endpoint is GET /v1/inAppPurchasesV2/{IAP_ID}/appStoreReviewScreenshot. A 404 or null data means it hasn't been uploaded.
Upload flow (Python, using the multipart reservation + upload + confirm pattern):
import requests, base64, os
def upload_iap_screenshot(iap_id: str, image_path: str, token: str) -> str:
"""Upload IAP App Review screenshot. Returns screenshotId."""
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
# Step 1: Reserve upload slot
img = open(image_path, "rb").read()
reservation = requests.post(
"https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots",
headers=headers,
json={"data": {"type": "inAppPurchaseAppStoreReviewScreenshots",
"attributes": {"fileSize": len(img), "fileName": os.path.basename(image_path)},
"relationships": {"inAppPurchaseV2": {"data": {"type": "inAppPurchasesV2", "id": iap_id}}}}}
).json()
screenshot_id = reservation["data"]["id"]
upload_ops = reservation["data"]["attributes"]["uploadOperations"]
# Step 2: Binary PUT to Apple's CDN (pre-signed URL)
for op in upload_ops:
put_headers = {h["name"]: h["value"] for h in op["requestHeaders"]}
chunk = img[op["offset"]:op["offset"] + op["length"]]
requests.put(op["url"], headers=put_headers, data=chunk)
# Step 3: Commit
requests.patch(
f"https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots/{screenshot_id}",
headers=headers,
json={"data": {"type": "inAppPurchaseAppStoreReviewScreenshots", "id": screenshot_id,
"attributes": {"uploaded": True}}}
)
return screenshot_id
Issue 3: Territory Availability Was Never Set (404)
This one is subtle. When you create an IAP via the API, the inAppPurchaseAvailabilities resource does not exist by default. The GET returns 404, not an empty list.
Apple's internal error code for this is IAP_SUBMISSION_NOT_ALLOWED_AVAILABILITY_NEVER_SET. You will not see this string in your rejection email. You'll just get the generic 2.1(b) phrasing.
Fix — POST to create the availability resource (175 territories, available everywhere):
# First, build the territory relationships array (all 175 Apple territories)
# Abbreviated here — use 'jq' to expand or fetch from /v1/territories
curl -X POST \
-H "Authorization: Bearer $ASC_TOKEN" \
-H "Content-Type: application/json" \
"https://api.appstoreconnect.apple.com/v1/inAppPurchaseAvailabilities" \
-d '{
"data": {
"type": "inAppPurchaseAvailabilities",
"attributes": {"availableInNewTerritories": true},
"relationships": {
"inAppPurchase": {"data": {"type": "inAppPurchasesV2", "id": "{IAP_ID}"}},
"availableTerritories": {
"data": [
{"type": "territories", "id": "USA"},
{"type": "territories", "id": "JPN"},
{"type": "territories", "id": "GBR"}
]
}
}
}
}'
Key: this is a POST, not a PATCH. The resource does not exist yet. If you try PATCH or PUT, you'll get a 404. Once created, subsequent changes use PATCH.
Issue 4: FIRST_IAP_MUST_BE_SUBMITTED_ON_VERSION (The Real Time-Sink)
This is the one that burns an hour. When submitting an IAP for review via the pure API path (POST /v1/inAppPurchaseSubmissions), you get:
{
"errors": [{
"code": "FIRST_IAP_MUST_BE_SUBMITTED_ON_VERSION",
"title": "First in-app purchase must be submitted on a version",
"detail": "Your first in-app purchase must be submitted for review with a version."
}]
}
This error only happens for first-ever IAP submissions on an app. All subsequent IAPs can use the standalone API submission path. But the first one must be attached to a version and submitted together, through the web UI in App Store Connect.
The pure-API path (inAppPurchaseSubmissions) will always fail for first IAPs. This is not documented prominently anywhere. Apple Developer Forums thread 738194 has the discussion; the accepted workaround is the web UI version-attach flow.
The Working Fix Flow
Run a 4-step preflight to get the IAP to READY_TO_SUBMIT, then use CDP browser automation to handle the version-attach:
# dashboard/asc_iap_submit_for_review.py — condensed preflight
def iap_preflight(iap_id: str, token: str) -> dict:
"""Run 4-step preflight: loc / screenshot / availability / state check."""
results = {}
# 1. Ensure en-US localization exists
locs = asc_get(f"/v1/inAppPurchasesV2/{iap_id}/localizations", token)
locales = {l["attributes"]["locale"] for l in locs.get("data", [])}
if "en-US" not in locales:
create_en_us_localization(iap_id, token)
results["en_us_loc"] = "CREATED"
else:
results["en_us_loc"] = "OK"
# 2. Ensure screenshot exists
ss = asc_get(f"/v1/inAppPurchasesV2/{iap_id}/appStoreReviewScreenshot", token)
if not ss.get("data"):
upload_iap_screenshot(iap_id, SCREENSHOT_PATH, token)
results["screenshot"] = "UPLOADED"
else:
results["screenshot"] = "OK"
# 3. Ensure territory availability exists (POST if 404)
try:
asc_get(f"/v1/inAppPurchasesV2/{iap_id}/iapPriceSchedule", token)
results["availability"] = "OK"
except AvailabilityNotFound:
create_territory_availability(iap_id, token)
results["availability"] = "CREATED"
# 4. Verify IAP state is READY_TO_SUBMIT
iap = asc_get(f"/v1/inAppPurchasesV2/{iap_id}", token)
results["state"] = iap["data"]["attributes"]["state"]
return results
After preflight confirms state == READY_TO_SUBMIT, the version-attach must happen through the web UI. The full CDP flow is in dashboard/asc_iap_resubmit_full_flow.py:
- Open
https://appstoreconnect.apple.com/apps/{APP_ID}/distribution/ios/version/inflight - Navigate to "In-App Purchases" section of the version
- Check the IAP checkbox to attach it
- Click "Submit for Review"
This submits both the version and the IAP together. After submission, verify via API:
curl -s -H "Authorization: Bearer $ASC_TOKEN" \
"https://api.appstoreconnect.apple.com/v1/inAppPurchasesV2/{IAP_ID}" \
| jq '.data.attributes.state'
# Should return: "WAITING_FOR_REVIEW"
curl -s -H "Authorization: Bearer $ASC_TOKEN" \
"https://api.appstoreconnect.apple.com/v1/appStoreVersions/{VERSION_ID}" \
| jq '.data.attributes.appStoreState'
# Should return: "WAITING_FOR_REVIEW"
Checklist for First-Submission IAPs
Before submitting any app version that contains a first-ever IAP:
- [ ] IAP has
en-USlocalization with name + description - [ ] IAP has App Review Screenshot uploaded and committed
- [ ]
inAppPurchaseAvailabilitiesresource exists (POST it if GET returns 404) - [ ] IAP pricing is set and saved
- [ ] IAP state is
READY_TO_SUBMIT(check via API, not just the UI) - [ ] Version-attach happens through the web UI, not via
inAppPurchaseSubmissionsAPI - [ ] Both IAP and Version show
WAITING_FOR_REVIEWafter submission
Debugging Tools
The 8-class ASC diagnostic script (orchestrator/asc-tools/asc_diag.py) now covers IAP completeness as its own class. Run it before every submission:
python orchestrator/asc-tools/asc_diag.py --all
# Output covers: build, locale, iap, screenshot, availability, pricing, state, submission
The reviewSubmissions 3-step flow for version submissions is covered in article #78 in this series. The IAP submission is a separate layer on top of that — both must succeed for the review to proceed.
References
- Apple Developer Forums — FIRST_IAP_MUST_BE_SUBMITTED_ON_VERSION discussion
- App Store Connect API — inAppPurchasesV2
- App Store Connect API — inAppPurchaseAvailabilities
- App Store Review Guidelines — 2.1 App Completeness
- WWDC 2022 — What's new in App Store Connect
Going Further
The IAP rejection is one node in a larger graph of TF/review failures that affect first-submission apps. The full debug bible — 8 failure classes, 30+ API examples, CDP automation scripts — is available as TestFlight Debug Bible ($29 on Gumroad). It covers the Apple 4-year cache bug, the reviewSubmissions migration, screenshot dimension mismatches, and the IAP completeness preflight in full detail.
If this post saved you an hour of Apple forum archaeology, the bible will save you the rest of the day.
If this helped you, here are the tools I actually use
14 iOS App Store Rejection Reasons (FREE) — FREE — pattern-match your rejection in 90 seconds
TF Debug Bible — $29 — the TestFlight 4-year bug + 7 cache resets that worked
ASC API Toolkit — $499 — 60+ Python scripts to ship/recover via API instead of UI clicks
Top comments (0)