DEV Community

孫昊
孫昊

Posted on

Apple Guideline 2.1(b) IAP Rejection — The 4 Hidden Causes and the Real Fix

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:

  1. Created in App Store Connect
  2. Localized in at least en-US (plus any other locales your app supports)
  3. Have an App Review Screenshot attached
  4. Have territory availability set (non-empty)
  5. Be attached to the app version being submitted
  6. 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
Enter fullscreen mode Exit fullscreen mode

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}"}
        }
      }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}
          ]
        }
      }
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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."
  }]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Open https://appstoreconnect.apple.com/apps/{APP_ID}/distribution/ios/version/inflight
  2. Navigate to "In-App Purchases" section of the version
  3. Check the IAP checkbox to attach it
  4. 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"
Enter fullscreen mode Exit fullscreen mode

Checklist for First-Submission IAPs

Before submitting any app version that contains a first-ever IAP:

  • [ ] IAP has en-US localization with name + description
  • [ ] IAP has App Review Screenshot uploaded and committed
  • [ ] inAppPurchaseAvailabilities resource 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 inAppPurchaseSubmissions API
  • [ ] Both IAP and Version show WAITING_FOR_REVIEW after 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
Enter fullscreen mode Exit fullscreen mode

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


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

See the full Day 60 indie hacker tool stack ->

Top comments (0)