DEV Community

Cover image for How I Fixed Apple's 'IAP 2.1(b) - App Completeness' Rejection in 90 Minutes Using ASC API V2
孫昊
孫昊

Posted on • Originally published at jiejuefuyou.github.io

How I Fixed Apple's 'IAP 2.1(b) - App Completeness' Rejection in 90 Minutes Using ASC API V2

tl;dr — Apple's 2.1(b) App Completeness rejection email tells you "your IAPs were not submitted with the binary." It does not tell you the V2 reviewSubmissions endpoint requires inAppPurchases as a relationships.items entry, that baseTerritory: USA is mandatory on the IAP itself, or that the App Review Screenshot is a separate hard gate. I learned all three the painful way on 2026-05-06 when four binaries came back rejected on the same morning. Below is the runnable script that fixed it.

The rejection email (lightly redacted)

The morning of 2026-05-06 four near-identical emails landed in my inbox:

Submission ID: <sub_id>
Status: Rejected

Guideline 2.1 - Information Needed (App Completeness)

We were unable to review your in-app purchase product because it was not
submitted with your app's binary. Please ensure your in-app purchase
products are submitted along with the most recent version of your app.

Next Steps:
- Submit your in-app purchase products with your app binary in
  App Store Connect.
Enter fullscreen mode Exit fullscreen mode

Four apps. All four had IAPs configured. All four had the IAPs in the binary's StoreKit configuration. All four had passed xcrun stapler validate. So what was Apple actually asking for?

It turned out to be three distinct things, none of which the email mentions. This post is the post-mortem and the script that automated the fix. By the time I'd debugged the first app the second was running on the same script. Total wall-clock from "what is going on" to "all four resubmitted" was 90 minutes.

What the rejection actually meant

The 2.1(b) rejection in 2026 has shifted meaning since the V2 reviewSubmissions endpoint replaced appStoreVersionSubmissions. The error is now an umbrella for three separate failure modes:

Class Symptom Root cause
A IAP not in the V2 submission relationships block reviewer never sees the IAP
B baseTerritory missing on the IAP itself IAP price schedule has no anchor — submission accepted but reviewer's TestFlight build can't render the paywall
C App Review Screenshot missing from each IAP reviewer can't visually confirm IAP behavior

The Apple docs (reviewSubmissions reference) hint at A but not at B or C. Fix A alone and you'll be rejected again 24 hours later — ask me how I know.

The 12-class diagnostic before fixing anything

Before I touched the V2 API I ran my standing diagnostic script. Twelve checks, ordered by historical rejection frequency from a year of submissions across four apps. The script lives at orchestrator/asc-tools/asc_diag.py and the core pattern looks like this:

"""asc_diag_inline.py — paste-ready 12-class TestFlight install diagnostic.

Pre-reqs:
    pip install pyjwt cryptography requests
    export ASC_KEY_ID=...
    export ASC_ISSUER_ID=...
    export ASC_KEY_FILE=~/AuthKey_KEYID.p8
"""
from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass, field
from glob import glob
from pathlib import Path
from typing import Any

import jwt  # PyJWT
import requests

ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
JWT_ALG = "ES256"
JWT_AUD = "appstoreconnect-v1"
JWT_LIFETIME = 1140  # 19 minutes; Apple max is 20

# 12 stable issue codes — the entire ranking system collapses to these.
CODES = [
    "PAID_APPS_AGREEMENT_UNSIGNED",  # 60% of "App not available" cases
    "PRICING_MISSING",                # appPriceSchedule = 404
    "AVAILABILITY_TERRITORY_MISSING", # appAvailabilities missing region
    "TESTER_NOT_FOUND",               # tester not in beta group
    "TESTER_NOT_ACCEPTED",            # tester invited but state != ACCEPTED
    "BUILD_NOT_VALID",                # build.processingState != VALID
    "BUILD_NOT_IN_GROUP",             # internalGroup never had build added
    "BUILD_EXPIRED",                  # >90 days
    "LOCALIZATION_MISSING",           # betaAppLocalizations empty
    "IAP_NOT_IN_SUBMISSION",          # reviewSubmissions without inAppPurchases
    "IAP_BASE_TERRITORY_MISSING",     # iapPriceSchedule.baseTerritory != USA
    "IAP_REVIEW_SCREENSHOT_MISSING",  # required for first submission
]


@dataclass
class Finding:
    code: str
    severity: str   # "block" | "warn" | "info"
    detail: str
    fix_hint: str = ""

    @property
    def is_blocking(self) -> bool:
        return self.severity == "block"


@dataclass
class ASCClient:
    key_id: str
    issuer_id: str
    key_pem: bytes
    _token: str = ""
    _token_at: float = 0.0

    @classmethod
    def from_env(cls) -> "ASCClient":
        key_id = os.environ["ASC_KEY_ID"]
        issuer_id = os.environ["ASC_ISSUER_ID"]
        key_file = os.path.expanduser(os.environ["ASC_KEY_FILE"])
        # Glob support: ASC_KEY_FILE=~/AuthKey_*.p8
        candidates = glob(key_file) if any(c in key_file for c in "*?[") else [key_file]
        path = next((Path(p) for p in candidates if Path(p).is_file()), None)
        if path is None:
            raise FileNotFoundError(f"ASC key file not found: {key_file}")
        return cls(key_id=key_id, issuer_id=issuer_id, key_pem=path.read_bytes())

    def _jwt(self) -> str:
        if self._token and (time.time() - self._token_at) < (JWT_LIFETIME - 60):
            return self._token
        now = int(time.time())
        payload = {
            "iss": self.issuer_id,
            "iat": now,
            "exp": now + JWT_LIFETIME,
            "aud": JWT_AUD,
        }
        headers = {"alg": JWT_ALG, "kid": self.key_id, "typ": "JWT"}
        self._token = jwt.encode(payload, self.key_pem, algorithm=JWT_ALG, headers=headers)
        self._token_at = time.time()
        return self._token

    def get(self, path: str, **params: Any) -> dict:
        url = f"{ASC_BASE}{path}"
        resp = requests.get(
            url,
            headers={"Authorization": f"Bearer {self._jwt()}"},
            params={k: v for k, v in params.items() if v is not None},
            timeout=30,
        )
        if resp.status_code == 404:
            return {"data": [], "_status": 404}
        resp.raise_for_status()
        return resp.json()


def check_iap_submission_readiness(
    client: ASCClient, app_id: str
) -> list[Finding]:
    """Class 10/11/12 — IAP submission readiness."""
    out: list[Finding] = []
    iaps = client.get(f"/apps/{app_id}/inAppPurchasesV2", limit=200).get("data", [])
    if not iaps:
        return [Finding(
            "IAP_NOT_IN_SUBMISSION",
            "info",
            "App has no IAPs configured — skip class 10-12",
        )]
    for iap in iaps:
        iap_id = iap["id"]
        attrs = iap.get("attributes", {})
        product_id = attrs.get("productId", "?")
        # Class 11 — baseTerritory check via iapPriceSchedule
        sched = client.get(f"/inAppPurchases/{iap_id}/iapPriceSchedule")
        sched_data = sched.get("data") or {}
        base = (sched_data.get("relationships") or {}).get("baseTerritory") or {}
        if not (base.get("data") or {}).get("id"):
            out.append(Finding(
                "IAP_BASE_TERRITORY_MISSING",
                "block",
                f"IAP {product_id} has no baseTerritory on iapPriceSchedule",
                fix_hint=(
                    f"PATCH /iapPriceSchedules/<id> with relationships.baseTerritory "
                    "= {data: {type: 'territories', id: 'USA'}}"
                ),
            ))
        # Class 12 — App Review Screenshot
        sc = client.get(f"/inAppPurchases/{iap_id}/appStoreReviewScreenshot")
        if sc.get("_status") == 404 or not sc.get("data"):
            out.append(Finding(
                "IAP_REVIEW_SCREENSHOT_MISSING",
                "block",
                f"IAP {product_id} missing App Review Screenshot",
                fix_hint="POST /appStoreReviewScreenshots; upload PNG; commit upload op",
            ))
    return out


def diagnose(bundle_id: str) -> list[Finding]:
    client = ASCClient.from_env()
    apps = client.get("/apps", limit=200).get("data", [])
    app = next((a for a in apps if a["attributes"]["bundleId"] == bundle_id), None)
    if not app:
        return [Finding("APP_NOT_FOUND", "block", f"bundle_id {bundle_id} not in ASC")]
    findings: list[Finding] = []
    findings.extend(check_iap_submission_readiness(client, app["id"]))
    return findings


if __name__ == "__main__":
    import sys
    bundle_id = sys.argv[1] if len(sys.argv) > 1 else "com.jiejuefuyou.daysuntil"
    for f in diagnose(bundle_id):
        marker = "[X]" if f.is_blocking else "[!]"
        print(f"{marker} {f.code}: {f.detail}")
        if f.fix_hint:
            print(f"    fix: {f.fix_hint}")
Enter fullscreen mode Exit fullscreen mode

Run it against a bundle id and the output is unambiguous:

$ python asc_diag_inline.py com.jiejuefuyou.daysuntil
[X] IAP_BASE_TERRITORY_MISSING: IAP daysuntil_pro has no baseTerritory on iapPriceSchedule
    fix: PATCH /iapPriceSchedules/<id> with relationships.baseTerritory = {data: {type: 'territories', id: 'USA'}}
[X] IAP_REVIEW_SCREENSHOT_MISSING: IAP daysuntil_pro missing App Review Screenshot
    fix: POST /appStoreReviewScreenshots; upload PNG; commit upload op
Enter fullscreen mode Exit fullscreen mode

That's two of three classes. The third — "IAP not in V2 submission" — is invisible to the diagnostic because the submission hasn't been created yet. It's class A on the table above, and the next section is the V2 fix.

The V2 reviewSubmissions 3-step flow with inAppPurchases

The V2 endpoint replaced appStoreVersionSubmissions quietly during 2025. The migration guide says "use reviewSubmissions instead." What it doesn't say is that the new endpoint requires you to enumerate every submittable artifact via relationships.items — including IAPs as their own item type. Here's the runnable flow:

"""resubmit_with_iap.py — V2 reviewSubmissions with inAppPurchases relationship.

Three steps Apple's docs gloss over:
    1. POST /reviewSubmissions with platform + app relationship
    2. POST /reviewSubmissionItems for each artifact (version + every IAP)
    3. PATCH /reviewSubmissions/<id> with attributes.submitted = true

Pre-reqs: same JWT env vars as the diagnostic above.
"""
from __future__ import annotations

import os
import sys
import time
from glob import glob
from pathlib import Path
from typing import Any

import jwt
import requests

ASC_BASE = "https://api.appstoreconnect.apple.com/v1"
JWT_LIFETIME = 1140


def make_jwt() -> str:
    key_id = os.environ["ASC_KEY_ID"]
    issuer_id = os.environ["ASC_ISSUER_ID"]
    key_pat = os.path.expanduser(os.environ["ASC_KEY_FILE"])
    paths = glob(key_pat) if any(c in key_pat for c in "*?[") else [key_pat]
    pem = next(Path(p) for p in paths if Path(p).is_file()).read_bytes()
    now = int(time.time())
    return jwt.encode(
        {"iss": issuer_id, "iat": now, "exp": now + JWT_LIFETIME, "aud": "appstoreconnect-v1"},
        pem,
        algorithm="ES256",
        headers={"alg": "ES256", "kid": key_id, "typ": "JWT"},
    )


def asc_request(method: str, path: str, *, body: dict | None = None, params: dict | None = None) -> dict:
    url = f"{ASC_BASE}{path}"
    headers = {
        "Authorization": f"Bearer {make_jwt()}",
        "Content-Type": "application/json",
    }
    resp = requests.request(method, url, headers=headers, json=body, params=params, timeout=60)
    if resp.status_code >= 400:
        raise RuntimeError(f"{method} {path} -> {resp.status_code}: {resp.text[:500]}")
    return resp.json() if resp.text else {}


def find_app_id(bundle_id: str) -> str:
    apps = asc_request("GET", "/apps", params={"limit": 200}).get("data", [])
    app = next((a for a in apps if a["attributes"]["bundleId"] == bundle_id), None)
    if not app:
        raise RuntimeError(f"bundle_id {bundle_id} not found")
    return app["id"]


def find_pending_version_id(app_id: str) -> str:
    """Find the most recent appStoreVersion in PREPARE_FOR_SUBMISSION."""
    versions = asc_request(
        "GET",
        f"/apps/{app_id}/appStoreVersions",
        params={"limit": 5, "sort": "-versionString"},
    ).get("data", [])
    pending = [
        v for v in versions
        if v["attributes"].get("appStoreState") in {"PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED"}
    ]
    if not pending:
        raise RuntimeError(f"No pending version for app {app_id}")
    return pending[0]["id"]


def find_submittable_iap_ids(app_id: str) -> list[str]:
    """IAPs in READY_TO_SUBMIT are valid for inclusion."""
    iaps = asc_request(
        "GET",
        f"/apps/{app_id}/inAppPurchasesV2",
        params={"limit": 200},
    ).get("data", [])
    return [
        iap["id"] for iap in iaps
        if iap["attributes"].get("state") in {"READY_TO_SUBMIT", "DEVELOPER_ACTION_NEEDED"}
    ]


def step1_create_submission(app_id: str) -> str:
    body = {
        "data": {
            "type": "reviewSubmissions",
            "attributes": {"platform": "IOS"},
            "relationships": {
                "app": {"data": {"type": "apps", "id": app_id}},
            },
        }
    }
    resp = asc_request("POST", "/reviewSubmissions", body=body)
    return resp["data"]["id"]


def step2_add_item(submission_id: str, item_type: str, item_id: str) -> str:
    """item_type: 'appStoreVersions' | 'inAppPurchases'."""
    body = {
        "data": {
            "type": "reviewSubmissionItems",
            "relationships": {
                "reviewSubmission": {
                    "data": {"type": "reviewSubmissions", "id": submission_id},
                },
                # KEY: relationship name must match the item type
                item_type: {
                    "data": {"type": item_type, "id": item_id},
                },
            },
        }
    }
    resp = asc_request("POST", "/reviewSubmissionItems", body=body)
    return resp["data"]["id"]


def step3_submit(submission_id: str) -> None:
    body = {
        "data": {
            "type": "reviewSubmissions",
            "id": submission_id,
            "attributes": {"submitted": True},
        }
    }
    asc_request("PATCH", f"/reviewSubmissions/{submission_id}", body=body)


def resubmit(bundle_id: str) -> None:
    app_id = find_app_id(bundle_id)
    version_id = find_pending_version_id(app_id)
    iap_ids = find_submittable_iap_ids(app_id)
    print(f"app={app_id} version={version_id} iaps={iap_ids}")

    submission_id = step1_create_submission(app_id)
    print(f"  submission created: {submission_id}")

    step2_add_item(submission_id, "appStoreVersions", version_id)
    print(f"  added version {version_id}")

    for iap_id in iap_ids:
        step2_add_item(submission_id, "inAppPurchases", iap_id)
        print(f"  added IAP {iap_id}")

    step3_submit(submission_id)
    print(f"  SUBMITTED: https://appstoreconnect.apple.com/apps/{app_id}/distribution")


if __name__ == "__main__":
    bundle_id = sys.argv[1]
    resubmit(bundle_id)
Enter fullscreen mode Exit fullscreen mode

Three calls per artifact. The IAP relationship type is inAppPurchases, not inAppPurchasesV2 — that one bit me for forty minutes because the resource lives at /apps/<id>/inAppPurchasesV2 but the type discriminator inside relationships.items is the v1 name. This is consistent with the broader ASC API V2 quirks: V2 resources are reached via v1 paths in relationship blocks.

The 3 fixes wired into one runnable

The diagnostic told me what's missing. The submission script knows how to submit. To go from "rejected" to "resubmitted" I needed glue that:

  1. Sets baseTerritory: USA on every iapPriceSchedule that's missing it.
  2. Uploads a PNG App Review Screenshot for every IAP.
  3. Calls the V2 reviewSubmissions flow with inAppPurchases items.
"""fix_iap_21b.py — combined fix for class 11 + 12 + V2 submission.

Run after asc_diag_inline.py reports IAP_BASE_TERRITORY_MISSING or
IAP_REVIEW_SCREENSHOT_MISSING. Idempotent: re-running on a clean app is a no-op.
"""
from __future__ import annotations

import hashlib
import sys
from pathlib import Path

# Re-uses asc_request and find_* from resubmit_with_iap.py — paste both into one file.
from resubmit_with_iap import (
    asc_request,
    find_app_id,
    find_submittable_iap_ids,
    resubmit,
)


def fix_base_territory(iap_id: str) -> bool:
    """Class 11 — set baseTerritory on iapPriceSchedule."""
    sched = asc_request("GET", f"/inAppPurchases/{iap_id}/iapPriceSchedule")
    data = sched.get("data") or {}
    sched_id = data.get("id")
    if not sched_id:
        # No schedule yet — create one with baseTerritory
        body = {
            "data": {
                "type": "iapPriceSchedules",
                "relationships": {
                    "inAppPurchase": {"data": {"type": "inAppPurchases", "id": iap_id}},
                    "baseTerritory": {"data": {"type": "territories", "id": "USA"}},
                    "manualPrices": {"data": []},
                },
            }
        }
        asc_request("POST", "/iapPriceSchedules", body=body)
        return True
    base = (data.get("relationships") or {}).get("baseTerritory") or {}
    if (base.get("data") or {}).get("id") == "USA":
        return False  # already correct, idempotent
    body = {
        "data": {
            "type": "iapPriceSchedules",
            "id": sched_id,
            "relationships": {
                "baseTerritory": {"data": {"type": "territories", "id": "USA"}},
            },
        }
    }
    asc_request("PATCH", f"/iapPriceSchedules/{sched_id}", body=body)
    return True


def upload_review_screenshot(iap_id: str, png_path: Path) -> bool:
    """Class 12 — upload App Review Screenshot via 3-call upload protocol."""
    existing = asc_request("GET", f"/inAppPurchases/{iap_id}/appStoreReviewScreenshot")
    if (existing.get("data") or {}).get("id"):
        return False  # already uploaded

    png_bytes = png_path.read_bytes()
    md5 = hashlib.md5(png_bytes).hexdigest()  # noqa: S324 — Apple requires md5

    # 1. Reserve the asset
    body = {
        "data": {
            "type": "appStoreReviewScreenshots",
            "attributes": {
                "fileName": png_path.name,
                "fileSize": len(png_bytes),
                "sourceFileChecksum": md5,
            },
            "relationships": {
                "inAppPurchaseV2": {"data": {"type": "inAppPurchases", "id": iap_id}},
            },
        }
    }
    create = asc_request("POST", "/appStoreReviewScreenshots", body=body)
    asset_id = create["data"]["id"]
    upload_ops = create["data"]["attributes"]["uploadOperations"]

    # 2. PUT each upload operation (Apple chunks for files >4MB)
    import requests as _r
    for op in upload_ops:
        chunk = png_bytes[op["offset"] : op["offset"] + op["length"]]
        headers = {h["name"]: h["value"] for h in op["requestHeaders"]}
        r = _r.request(op["method"], op["url"], headers=headers, data=chunk, timeout=120)
        r.raise_for_status()

    # 3. Commit (must include checksum + uploaded=true)
    commit = {
        "data": {
            "type": "appStoreReviewScreenshots",
            "id": asset_id,
            "attributes": {
                "uploaded": True,
                "sourceFileChecksum": md5,
            },
        }
    }
    asc_request("PATCH", f"/appStoreReviewScreenshots/{asset_id}", body=commit)
    return True


def main(bundle_id: str, screenshot_path: str) -> int:
    app_id = find_app_id(bundle_id)
    iap_ids = find_submittable_iap_ids(app_id)
    if not iap_ids:
        print(f"No submittable IAPs for {bundle_id} — nothing to fix")
        return 0
    png = Path(screenshot_path)
    if not png.is_file():
        raise FileNotFoundError(screenshot_path)

    fixed_count = 0
    for iap_id in iap_ids:
        if fix_base_territory(iap_id):
            print(f"  fixed baseTerritory for IAP {iap_id}")
            fixed_count += 1
        if upload_review_screenshot(iap_id, png):
            print(f"  uploaded review screenshot for IAP {iap_id}")
            fixed_count += 1

    if fixed_count == 0:
        print("All IAP fields already correct — re-submitting only")
    resubmit(bundle_id)
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2]))
Enter fullscreen mode Exit fullscreen mode

Invoke it once per app:

python fix_iap_21b.py com.jiejuefuyou.daysuntil  /assets/iap-screenshot-1024.png
python fix_iap_21b.py com.jiejuefuyou.autochoice /assets/iap-screenshot-1024.png
python fix_iap_21b.py com.jiejuefuyou.altitudenow /assets/iap-screenshot-1024.png
python fix_iap_21b.py com.jiejuefuyou.promptvault /assets/iap-screenshot-1024.png
Enter fullscreen mode Exit fullscreen mode

Four calls. Roughly 22 seconds each on my home connection. The four submissions sat in WAITING_FOR_REVIEW and three of four were approved within 28 hours. The one holdout had a separate DSA Trader status problem unrelated to 2.1(b) — different post.

Why the 12-class diagnostic matters before you touch the V2 API

The 90 minute number isn't from speed-typing. It's from running asc_diag.py --all first so I knew exactly which of the 12 classes was the actual blocker for each app. Without that step the natural instinct is to start re-uploading screenshots randomly. The diagnostic is the cheap fast feedback loop — submission attempts cost an hour of Apple latency per round.

If you want the unabridged 12-class checker as one drop-in module, the open-source version lives at asc_diag.py on GitHub and the companion deep-dive on the V2 reviewSubmissions endpoint walks through every relationship type Apple supports.

Three things I'd tell my 2026-05-06 morning self:

  1. The V2 reviewSubmissions endpoint needs inAppPurchases items explicitly. The binary alone is not enough.
  2. baseTerritory: USA is a hard requirement on iapPriceSchedule. Apple's UI auto-fills it; the API does not.
  3. App Review Screenshot is a separate three-call upload (reserve / PUT / commit), not a single POST.

If you've hit the same 2.1(b) rejection, run the diagnostic first, then run the fix script. 90 minutes is realistic. Two hours is realistic if your IAPs are configured weirdly. Three rounds of resubmission is realistic if you skip the diagnostic.

Further reading on the same stack:


If you ship iOS apps and hit the same wall

I packaged the full toolkit + 200 page playbook + cold email templates + lead magnet into four Gumroad SKUs:

  • iOS Indie Developer's ASC API Toolkit ($499) — the complete Python source for the diagnostic + V2 reviewSubmissions flow + IAP automation: vszsui
  • iOS Indie Launch Playbook ($39) — the 200-page execution guide for "rejection to approval" loops: hmmzt
  • B2B Cold Email Templates for iOS Devs ($19) — pitch the same toolkit to indie clients: gncbck
  • 14 Common iOS Rejection Reasons (FREE lead magnet) — the curated list every Apple rejection email actually maps to: sphytu

Top comments (0)