# Apple Appstore Manager

> Apple App Store Connect API manager for iOS and macOS app management. Use this skill when managing iOS/macOS apps, in-app purchases, subscriptions, app metadata, TestFlight, app submissions, or adding macOS platform to existing iOS apps via the App Store Connect API. Triggers on keywords like "App Store Connect", "iOS products", "TestFlight", "app submission", "Apple IAP", "iOS subscription", "Mac App Store", "macOS deploy".

- Skill: `iml1s/apple-appstore-manager` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add iml1s/apple-appstore-manager`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iml1s/apple-appstore-manager/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ImL1s (https://skillmd.com/u/iml1s)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iml1s/apple-appstore-manager

---


# Apple App Store Connect Manager Skill

This skill enables you to manage iOS apps and in-app purchases through the App Store Connect API.

> [!CAUTION]
> ## Mandatory Completion Checklist (IAP / Subscriptions)
> When setting up subscriptions, you MUST complete ALL steps below via API.
> **NEVER skip a step or tell the user "this needs to be done manually on the web".**
> Every step has working API code in this skill.
>
> - [ ] Find app by bundle ID (`GET /v1/apps?filter[bundleId]=...`)
> - [ ] Create subscription group (`POST /v1/subscriptionGroups`)
> - [ ] Create subscriptions with period + review note (`POST /v1/subscriptions`)
> - [ ] Create localizations for ALL target locales (`POST /v1/subscriptionLocalizations`)
> - [ ] **Set pricing via PATCH + equalizations** (see §4. Subscription Pricing — this is NOT manual)
> - [ ] Verify prices via API (`GET /v1/subscriptions/{id}/prices`)
>
> ### Cross-Skill Pipeline (Full IAP Setup)
> A complete IAP setup requires THREE skills in sequence:
> 1. **This skill** (apple-appstore-manager) → Create ASC subscriptions + pricing
> 2. **google-play-manager** → Create Google Play subscriptions + basePlans + activate
> 3. **revenuecat-manager** → Create RC apps/products/offerings/packages/entitlements, get SDK keys

## Known Apps

| Project | Bundle ID | Status |
|---------|-----------|--------|
| YourApp | `com.example.yourapp` | Internal Testing

## Authentication

### Credentials Location
Credentials are stored at:
- **Directory**: `~/.app_store_credentials/`
- **Private Key**: `~/.app_store_credentials/AuthKey_<YOUR_KEY_ID>.p8`
- **Environment**: `~/.app_store_credentials/.env`

### Load Credentials
```bash
source ~/.app_store_credentials/.env
echo "Key ID: $APP_STORE_CONNECT_KEY_ID"
echo "Issuer ID: $APP_STORE_CONNECT_ISSUER_ID"
echo "Key Path: $APP_STORE_CONNECT_KEY_PATH"
```

### Important: Use Python Instead of curl

**`curl` with single-quoted arguments often fails in Claude Code's bash tool due to shell quoting issues.** Always use `python3` with `requests` library for API calls. Generate JWT with PyJWT, then use `requests.get/post`.

### JWT Generation (Required for Every Request)

Apple uses JWT with ES256 signing. JWTs expire after 20 minutes.

**Generate JWT using Ruby (recommended):**
```bash
source ~/.app_store_credentials/.env

ruby -e '
require "jwt"
require "openssl"

key_file = ENV["APP_STORE_CONNECT_KEY_PATH"]
key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]

private_key = OpenSSL::PKey::EC.new(File.read(key_file))

payload = {
  iss: issuer_id,
  iat: Time.now.to_i,
  exp: Time.now.to_i + 20 * 60,  # 20 minutes
  aud: "appstoreconnect-v1"
}

token = JWT.encode(payload, private_key, "ES256", { kid: key_id })
puts token
'
```

**Generate JWT using Python:**
```bash
source ~/.app_store_credentials/.env

python3 << 'EOF'
import jwt
import time
import os

with open(os.environ["APP_STORE_CONNECT_KEY_PATH"], "r") as f:
    private_key = f.read()

payload = {
    "iss": os.environ["APP_STORE_CONNECT_ISSUER_ID"],
    "iat": int(time.time()),
    "exp": int(time.time()) + 20 * 60,
    "aud": "appstoreconnect-v1"
}

token = jwt.encode(
    payload,
    private_key,
    algorithm="ES256",
    headers={"kid": os.environ["APP_STORE_CONNECT_KEY_ID"]}
)
print(token)
EOF
```

**Store JWT for reuse:**
```bash
export ASC_TOKEN=$(python3 << 'EOF'
import jwt, time, os
with open(os.environ["APP_STORE_CONNECT_KEY_PATH"], "r") as f:
    private_key = f.read()
token = jwt.encode(
    {"iss": os.environ["APP_STORE_CONNECT_ISSUER_ID"], "iat": int(time.time()), "exp": int(time.time()) + 1200, "aud": "appstoreconnect-v1"},
    private_key, algorithm="ES256", headers={"kid": os.environ["APP_STORE_CONNECT_KEY_ID"]}
)
print(token)
EOF
)
```

## API Endpoints

Base URL: `https://api.appstoreconnect.apple.com/v1`

### Request Format
```bash
curl -s 'https://api.appstoreconnect.apple.com/v1/{endpoint}' \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json"
```

---

## Known Apps

Track your team's apps here as you register them:

| Project | Bundle ID | App ID | Status |
|---------|-----------|--------|--------|
| `<YourApp>` | `com.example.yourapp` | `<numeric-app-id>` | Production / Prepare / Review |

> Tip: To auto-populate this table, query App Store Connect API (`/v1/apps?fields[apps]=bundleId,name`) with a JWT-signed token — see Apple's [App Store Connect API docs](https://developer.apple.com/documentation/appstoreconnectapi/list_apps) for the bearer-token format.

---

## macOS Platform Management

### Adding macOS Platform to an Existing iOS App

If the app's Bundle ID is registered as `UNIVERSAL`, you can add a macOS version directly via API:

```python
# Create a macOS App Store Version for an existing app
APP_ID = "<YOUR_APP_ID>"  # Your app ID

r = requests.post(f"{BASE}/appStoreVersions", headers=headers, json={
    "data": {
        "type": "appStoreVersions",
        "attributes": {
            "versionString": "1.0.0",
            "platform": "MAC_OS"
        },
        "relationships": {
            "app": {
                "data": {"type": "apps", "id": APP_ID}
            }
        }
    }
})
# Returns 201 with state: PREPARE_FOR_SUBMISSION
```

**Prerequisites:**
- Bundle ID must be `UNIVERSAL` platform (check via `GET /v1/bundleIds?filter[identifier]=...`)
- If Bundle ID is `IOS` only, you need to create a new Bundle ID or update it in the Developer Portal

**Check Bundle ID platform:**
```python
r = requests.get(f"{BASE}/bundleIds?filter[identifier]=com.example.app", headers=headers)
platform = r.json()["data"][0]["attributes"]["platform"]
# "UNIVERSAL" = iOS + macOS, "IOS" = iOS only, "MAC_OS" = macOS only
```

### Copying iOS Metadata to macOS Version

When adding macOS to an existing iOS app, you typically want the same metadata. Apple auto-creates empty localizations for the macOS version matching your iOS locales.

```python
IOS_VERSION_ID = "ios-version-id"
MACOS_VERSION_ID = "macos-version-id"

# 1. Get iOS localizations
r = requests.get(f"{BASE}/appStoreVersions/{IOS_VERSION_ID}/appStoreVersionLocalizations?limit=50", headers=headers)
ios_locs = r.json().get("data", [])

# 2. Get macOS localizations (auto-created, but empty)
r = requests.get(f"{BASE}/appStoreVersions/{MACOS_VERSION_ID}/appStoreVersionLocalizations?limit=50", headers=headers)
mac_locs = {loc["attributes"]["locale"]: loc["id"] for loc in r.json().get("data", [])}

# 3. PATCH each macOS localization with iOS data
for ios_loc in ios_locs:
    attrs = ios_loc["attributes"]
    locale = attrs["locale"]

    loc_attrs = {}
    for field in ["description", "keywords", "supportUrl", "marketingUrl", "promotionalText", "whatsNew"]:
        val = attrs.get(field)
        if val:
            loc_attrs[field] = val

    if not loc_attrs or locale not in mac_locs:
        continue

    requests.patch(f"{BASE}/appStoreVersionLocalizations/{mac_locs[locale]}", headers=headers, json={
        "data": {
            "type": "appStoreVersionLocalizations",
            "id": mac_locs[locale],
            "attributes": loc_attrs
        }
    })
```

### macOS Build + Sign + Upload (Flutter)

Flutter `build macos --release` produces ad-hoc signed apps. For Mac App Store, use `xcodebuild archive`:

```bash
# Step 1: xcodebuild archive (proper signing)
xcodebuild archive \
  -workspace macos/Runner.xcworkspace \
  -scheme Runner \
  -configuration Release \
  -archivePath build/macos/App.xcarchive \
  CODE_SIGN_STYLE=Automatic \
  DEVELOPMENT_TEAM=<YOUR_TEAM_ID>

# Step 2: Export as pkg
xcodebuild -exportArchive \
  -archivePath build/macos/App.xcarchive \
  -exportPath build/macos/pkg \
  -exportOptionsPlist ExportOptions.plist

# Step 3: Upload (xcrun altool is fully deprecated since Nov 2023)
# Use Transporter CLI or fastlane instead:
/usr/bin/xcrun iTMSTransporter -m upload \
  -f build/macos/pkg/App.pkg \
  -apiKey "$API_KEY" \
  -apiIssuer "$API_ISSUER"
# Alternative: Use the Transporter app from Mac App Store (GUI)
```

**ExportOptions.plist for Mac App Store:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>method</key>
    <string>app-store</string>
    <key>teamID</key>
    <string><YOUR_TEAM_ID></string>
    <key>destination</key>
    <string>upload</string>
    <key>signingStyle</key>
    <string>automatic</string>
    <key>uploadSymbols</key>
    <true/>
</dict>
</plist>
```

**CocoaPods `set -u` Fix:** If `xcodebuild archive` fails with `source: unbound variable` in `Pods-Runner-frameworks.sh`, patch the `install_framework()` function to initialize `local source=""` before the if/elif chain. This is a known CocoaPods issue with `set -u`.

**Duplicate Certificate Fix:** If `codesign` shows `ambiguous (matches ... and ...)`, use the SHA-1 hash instead of the certificate name:
```bash
# Get SHA-1 hash of the first matching cert
CERT_HASH=$(security find-identity -v -p codesigning | grep "Apple Distribution.*($TEAM_ID)" | head -1 | awk '{print $2}')
codesign --force --sign "$CERT_HASH" --options runtime --entitlements "$ENT" "$APP"
```

**Required Certificates:**
- `Apple Distribution: <name> (<TEAM_ID>)` — signs the .app
- `3rd Party Mac Developer Installer: <name> (<TEAM_ID>)` — signs the .pkg

**Alternative: Manual Re-sign After Flutter Build:**
```bash
# If xcodebuild archive doesn't work, re-sign the flutter build output
APP="build/macos/Build/Products/Release/MyApp.app"
CERT="Apple Distribution: <Your Name> (<YOUR_TEAM_ID>)"
ENT="macos/Runner/Release.entitlements"

# Sign all embedded frameworks first
find "$APP/Contents/Frameworks" -name "*.framework" -exec \
  codesign --force --sign "$CERT" --entitlements "$ENT" {} \;

# Sign the main app
codesign --force --sign "$CERT" --entitlements "$ENT" "$APP"

# Create signed pkg
productbuild --component "$APP" /Applications \
  --sign "3rd Party Mac Developer Installer: <Your Name> (<YOUR_TEAM_ID>)" \
  build/macos/App.pkg
```

### Provisioning Profile Management via API

#### List Profiles for a Bundle ID
```python
# Get bundle ID record first
r = requests.get(f"{BASE}/bundleIds?filter[identifier]=com.example.app", headers=HEADERS)
bid = r.json()["data"][0]["id"]

# List profiles
r = requests.get(f"{BASE}/bundleIds/{bid}/profiles", headers=HEADERS)
for p in r.json()["data"]:
    print(f"{p['attributes']['name']} | {p['attributes']['profileType']} | {p['attributes']['profileState']}")
```

#### Create MAC_APP_STORE Profile
```python
import base64

# Get all distribution certificates
r = requests.get(f"{BASE}/certificates?filter[certificateType]=DISTRIBUTION", headers=HEADERS)
cert_ids = [c["id"] for c in r.json()["data"]]

# Create profile
r = requests.post(f"{BASE}/profiles", headers=HEADERS, json={
    "data": {
        "type": "profiles",
        "attributes": {
            "name": "Mac App Store com.example.app",
            "profileType": "MAC_APP_STORE"
        },
        "relationships": {
            "bundleId": {"data": {"type": "bundleIds", "id": bid}},
            "certificates": {"data": [{"type": "certificates", "id": cid} for cid in cert_ids]}
        }
    }
})

# Save profile content
profile = r.json()["data"]
profile_bytes = base64.b64decode(profile["attributes"]["profileContent"])
uuid = profile["attributes"]["uuid"]

# Save to Xcode profiles directory
with open(f"~/Library/Developer/Xcode/UserData/Provisioning Profiles/{uuid}.provisionprofile", "wb") as f:
    f.write(profile_bytes)
```

**Profile Types:**
| Type | Platform | Usage |
|------|----------|-------|
| `IOS_APP_STORE` | iOS | App Store distribution |
| `MAC_APP_STORE` | macOS | Mac App Store distribution |
| `IOS_APP_DEVELOPMENT` | iOS | Development/testing |
| `MAC_APP_DEVELOPMENT` | macOS | Development/testing |

**Important:** UNIVERSAL bundle IDs (supporting both iOS and macOS) need separate profiles for each platform type.

#### Mac App Store Entitlements (ITMS-90886 Fix)

When manually signing for Mac App Store, entitlements MUST include:
```xml
<key>com.apple.application-identifier</key>
<string>TEAM_ID.BUNDLE_ID</string>
<key>com.apple.developer.team-identifier</key>
<string>TEAM_ID</string>
```

Missing these causes ITMS-90886: "missing an application identifier" — upload succeeds but build is rejected for TestFlight.

---

## Common Operations

### 1. List All Apps
```bash
curl -s 'https://api.appstoreconnect.apple.com/v1/apps' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

### 2. Get App Details
```bash
curl -s 'https://api.appstoreconnect.apple.com/v1/apps/{app_id}' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

### 3. List In-App Purchases for App
```bash
curl -s 'https://api.appstoreconnect.apple.com/v1/apps/{app_id}/inAppPurchasesV2' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

### 4. Create In-App Purchase

> **NOTE**: The v1 endpoint `POST /v1/inAppPurchases` returns `403 FORBIDDEN` for consumables.
> Use the **v2** endpoint instead: `POST /v2/inAppPurchases`.

```bash
curl -s -X POST 'https://api.appstoreconnect.apple.com/v2/inAppPurchases' \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchases",
      "attributes": {
        "name": "Starter Pack - 80 Credits",
        "productId": "credits_starter_80",
        "inAppPurchaseType": "CONSUMABLE",
        "reviewNote": "80 credits for AI Agent usage"
      },
      "relationships": {
        "app": {
          "data": {
            "type": "apps",
            "id": "{app_id}"
          }
        }
      }
    }
  }'
```

### 5. Update In-App Purchase
```bash
curl -s -X PATCH 'https://api.appstoreconnect.apple.com/v1/inAppPurchases/{iap_id}' \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchases",
      "id": "{iap_id}",
      "attributes": {
        "name": "Updated Name"
      }
    }
  }'
```

### 6. Delete In-App Purchase
```bash
curl -s -X DELETE 'https://api.appstoreconnect.apple.com/v1/inAppPurchases/{iap_id}' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

### 7. List Subscription Groups
```bash
curl -s 'https://api.appstoreconnect.apple.com/v1/apps/{app_id}/subscriptionGroups' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

### 8. Create Subscription Group
```bash
curl -s -X POST 'https://api.appstoreconnect.apple.com/v1/subscriptionGroups' \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "subscriptionGroups",
      "attributes": {
        "referenceName": "Pro Subscriptions"
      },
      "relationships": {
        "app": {
          "data": {
            "type": "apps",
            "id": "{app_id}"
          }
        }
      }
    }
  }'
```

### 9. List Subscriptions in Group
```bash
curl -s 'https://api.appstoreconnect.apple.com/v1/subscriptionGroups/{group_id}/subscriptions' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

---

## Complete IAP Product Configuration Guide

This section provides a complete workflow for configuring In-App Purchase products with pricing, localization, and availability.

### Overview of Configuration Steps

1. Get the IAP product ID from your app
2. Configure localization (display name, description)
3. Set availability (territories)
4. Configure pricing and price schedule
5. Verify configuration via API or web interface

### Step 1: Get IAP Product IDs

List all IAP products for an app to get their IDs:

```bash
APP_ID="<YOUR_APP_ID>"  # Your app ID

curl -s "https://api.appstoreconnect.apple.com/v1/apps/${APP_ID}/inAppPurchasesV2" \
  -H "Authorization: Bearer $ASC_TOKEN" | python3 -m json.tool
```

Example response structure:
```json
{
  "data": [
    {
      "id": "<YOUR_IAP_ID_MONTHLY>",
      "type": "inAppPurchases",
      "attributes": {
        "name": "月訂閱",
        "productId": "<your_product_id>",
        "inAppPurchaseType": "AUTOMATICALLY_RENEWABLE_SUBSCRIPTION"
      }
    }
  ]
}
```

### Step 2: Configure Localization

Create localization for an IAP product:

```bash
IAP_ID="<YOUR_IAP_ID>"  # Your IAP ID

curl -s -X POST "https://api.appstoreconnect.apple.com/v1/inAppPurchaseLocalizations" \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchaseLocalizations",
      "attributes": {
        "locale": "zh-Hant",
        "name": "終身會員",
        "description": "一次付費永久使用,包含所有專業版功能"
      },
      "relationships": {
        "inAppPurchase": {
          "data": {
            "type": "inAppPurchases",
            "id": "'"${IAP_ID}"'"
          }
        }
      }
    }
  }'
```

**Important Notes:**
- Use `zh-Hant` for Traditional Chinese (not `zh-TW`)
- Use `zh-Hans` for Simplified Chinese
- Each locale requires separate localization creation
- Localization must be created before setting prices

### Step 3: Set Availability (Territories)

Configure which territories the IAP is available in:

```bash
IAP_ID="<YOUR_IAP_ID>"

curl -s -X POST "https://api.appstoreconnect.apple.com/v1/inAppPurchaseAvailabilities" \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchaseAvailabilities",
      "attributes": {
        "availableInNewTerritories": true
      },
      "relationships": {
        "availableTerritories": {
          "data": [
            {"type": "territories", "id": "TWN"}
          ]
        },
        "inAppPurchase": {
          "data": {
            "type": "inAppPurchases",
            "id": "'"${IAP_ID}"'"
          }
        }
      }
    }
  }'
```

**Territory Codes:**
- `TWN` - Taiwan
- `USA` - United States
- `CHN` - China
- `JPN` - Japan
- etc.

### Step 4: Configure Pricing (The Complex Part)

#### 4.1 Understanding Price Points

Apple uses **Price Point IDs** which are Base64-encoded JSON strings:

```json
{
  "s": "IAP_PRODUCT_ID",
  "t": "TERRITORY_CODE",
  "p": "PRICE_TIER"
}
```

Example: `eyJzIjoiNjc1NjQ4MzMyOCIsInQiOiJUV04iLCJwIjoiMTAzNjMifQ`

Decodes to:
```json
{
  "s": "<YOUR_IAP_ID>",
  "t": "TWN",
  "p": "10363"
}
```

#### 4.2 Find Price Points for a Territory

**Method 1: Direct Query (Fast)**
```bash
IAP_ID="<YOUR_IAP_ID>"
TERRITORY="TWN"

curl -s "https://api.appstoreconnect.apple.com/v1/inAppPurchasePricePoints?filter[territory]=${TERRITORY}&filter[inAppPurchase]=${IAP_ID}&limit=200" \
  -H "Authorization: Bearer $ASC_TOKEN"
```

**Method 2: Pagination (For All Price Points)**
```python
import requests
import json
import os

ASC_TOKEN = os.environ['ASC_TOKEN']
IAP_ID = "<YOUR_IAP_ID>"
TERRITORY = "TWN"

url = f"https://api.appstoreconnect.apple.com/v1/inAppPurchasePricePoints"
headers = {
    "Authorization": f"Bearer {ASC_TOKEN}",
    "Content-Type": "application/json"
}

all_prices = []
params = {
    "filter[territory]": TERRITORY,
    "filter[inAppPurchase]": IAP_ID,
    "limit": 200
}

while True:
    response = requests.get(url, headers=headers, params=params)
    data = response.json()

    if 'data' in data:
        all_prices.extend(data['data'])

    # Check for next page
    if 'links' in data and 'next' in data['links']:
        url = data['links']['next']
        params = {}  # Next URL already includes params
    else:
        break

# Find specific price (e.g., NT$2990)
for price in all_prices:
    if 'customerPrice' in price['attributes']:
        customer_price = float(price['attributes']['customerPrice'])
        if abs(customer_price - 2990.0) < 0.01:
            print(f"Found NT$2990:")
            print(f"  Price Point ID: {price['id']}")
            print(f"  Tier: {price['attributes'].get('priceTier')}")
            break
```

#### 4.3 Common Taiwan Price Tiers

Based on actual API queries, here are commonly used Taiwan price tiers:

| Price (TWD) | Tier | Price Point ID (example) |
|------------|------|-------------------------|
| $30 | 10001 | eyJzIjoiNjc1NjQ4MzQwMCIsInQiOiJUV04iLCJwIjoiMTAwMDEifQ |
| $140 | 10039 | eyJzIjoiNjc1NjQ4MzQwMCIsInQiOiJUV04iLCJwIjoiMTAwMzkifQ |
| $990 | 10190 | eyJzIjoiNjc1NjQ4MzI3MiIsInQiOiJUV04iLCJwIjoiMTAxOTAifQ |
| $2,990 | 10363 | eyJzIjoiNjc1NjQ4MzMyOCIsInQiOiJUV04iLCJwIjoiMTAzNjMifQ |

**Note:** Price Point IDs include the IAP Product ID (`s` field), so they differ per product even for the same price tier.

#### 4.4 Create Price Schedule

Once you have the Price Point ID, create a price schedule:

```bash
IAP_ID="<YOUR_IAP_ID>"
PRICE_POINT_ID="eyJzIjoiNjc1NjQ4MzMyOCIsInQiOiJUV04iLCJwIjoiMTAzNjMifQ"

# Generate unique temporary ID for the price relationship
PRICE_TEMP_ID="price_$(date +%s)"

curl -s -X POST "https://api.appstoreconnect.apple.com/v1/inAppPurchasePriceSchedules" \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchasePriceSchedules",
      "relationships": {
        "inAppPurchase": {
          "data": {
            "type": "inAppPurchases",
            "id": "'"${IAP_ID}"'"
          }
        },
        "manualPrices": {
          "data": [
            {
              "type": "inAppPurchasePrices",
              "id": "'"${PRICE_TEMP_ID}"'"
            }
          ]
        },
        "baseTerritory": {
          "data": {
            "type": "territories",
            "id": "TWN"
          }
        }
      }
    },
    "included": [
      {
        "type": "inAppPurchasePrices",
        "id": "'"${PRICE_TEMP_ID}"'",
        "relationships": {
          "inAppPurchasePricePoint": {
            "data": {
              "type": "inAppPurchasePricePoints",
              "id": "'"${PRICE_POINT_ID}"'"
            }
          }
        }
      }
    ]
  }' | python3 -m json.tool
```

**Key Points:**
- `baseTerritory`: The reference territory for pricing (usually your home market)
- `manualPrices`: Array of price relationships
- `included`: Contains the actual price point mapping
- Temporary ID in `manualPrices.data[].id` must match `included[].id`
- The API will automatically calculate equivalent prices for all other territories

#### 4.5 Verify Price Configuration

Check if price schedule was created successfully:

```bash
IAP_ID="<YOUR_IAP_ID>"

curl -s "https://api.appstoreconnect.apple.com/v1/inAppPurchases/${IAP_ID}/priceSchedule" \
  -H "Authorization: Bearer $ASC_TOKEN" | python3 -m json.tool
```

### Step 5: Complete Configuration Workflow

Here's a complete script that configures an IAP product from scratch:

```bash
#!/bin/bash
# Complete IAP Product Configuration Script

set -e  # Exit on error

# Load credentials
source ~/.app_store_credentials/.env

# Generate JWT
export ASC_TOKEN=$(python3 << 'EOF'
import jwt, time, os
with open(os.environ["APP_STORE_CONNECT_KEY_PATH"], "r") as f:
    private_key = f.read()
token = jwt.encode(
    {"iss": os.environ["APP_STORE_CONNECT_ISSUER_ID"],
     "iat": int(time.time()),
     "exp": int(time.time()) + 1200,
     "aud": "appstoreconnect-v1"},
    private_key,
    algorithm="ES256",
    headers={"kid": os.environ["APP_STORE_CONNECT_KEY_ID"]}
)
print(token)
EOF
)

# Configuration
APP_ID="<YOUR_APP_ID>"
IAP_ID="<YOUR_IAP_ID>"
PRODUCT_NAME="終身會員"
PRODUCT_DESC="一次付費永久使用，包含所有專業版功能"
TARGET_PRICE=2990.0
BASE_TERRITORY="TWN"

echo "=== Configuring IAP Product: ${IAP_ID} ==="

# Step 1: Create Localization
echo "Step 1: Creating localization..."
curl -s -X POST "https://api.appstoreconnect.apple.com/v1/inAppPurchaseLocalizations" \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchaseLocalizations",
      "attributes": {
        "locale": "zh-Hant",
        "name": "'"${PRODUCT_NAME}"'",
        "description": "'"${PRODUCT_DESC}"'"
      },
      "relationships": {
        "inAppPurchase": {
          "data": {"type": "inAppPurchases", "id": "'"${IAP_ID}"'"}
        }
      }
    }
  }' > /dev/null
echo "✓ Localization created"

# Step 2: Set Availability
echo "Step 2: Setting availability..."
curl -s -X POST "https://api.appstoreconnect.apple.com/v1/inAppPurchaseAvailabilities" \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchaseAvailabilities",
      "attributes": {"availableInNewTerritories": true},
      "relationships": {
        "availableTerritories": {
          "data": [{"type": "territories", "id": "'"${BASE_TERRITORY}"'"}]
        },
        "inAppPurchase": {
          "data": {"type": "inAppPurchases", "id": "'"${IAP_ID}"'"}
        }
      }
    }
  }' > /dev/null
echo "✓ Availability set"

# Step 3: Find Price Point
echo "Step 3: Finding price point for NT\$${TARGET_PRICE}..."
PRICE_POINT_ID=$(python3 << EOF
import requests, json

url = "https://api.appstoreconnect.apple.com/v1/inAppPurchasePricePoints"
headers = {"Authorization": "Bearer ${ASC_TOKEN}"}
params = {
    "filter[territory]": "${BASE_TERRITORY}",
    "filter[inAppPurchase]": "${IAP_ID}",
    "limit": 200
}

all_prices = []
while True:
    r = requests.get(url, headers=headers, params=params)
    data = r.json()
    if 'data' in data:
        all_prices.extend(data['data'])
    if 'links' in data and 'next' in data['links']:
        url = data['links']['next']
        params = {}
    else:
        break

for price in all_prices:
    if 'customerPrice' in price['attributes']:
        if abs(float(price['attributes']['customerPrice']) - ${TARGET_PRICE}) < 0.01:
            print(price['id'])
            break
EOF
)

if [ -z "$PRICE_POINT_ID" ]; then
    echo "✗ Price point not found for NT\$${TARGET_PRICE}"
    exit 1
fi
echo "✓ Found price point: ${PRICE_POINT_ID}"

# Step 4: Create Price Schedule
echo "Step 4: Creating price schedule..."
PRICE_TEMP_ID="price_$(date +%s)"
curl -s -X POST "https://api.appstoreconnect.apple.com/v1/inAppPurchasePriceSchedules" \
  -H "Authorization: Bearer $ASC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "type": "inAppPurchasePriceSchedules",
      "relationships": {
        "inAppPurchase": {"data": {"type": "inAppPurchases", "id": "'"${IAP_ID}"'"}},
        "manualPrices": {"data": [{"type": "inAppPurchasePrices", "id": "'"${PRICE_TEMP_ID}"'"}]},
        "baseTerritory": {"data": {"type": "territories", "id": "'"${BASE_TERRITORY}"'"}}
      }
    },
    "included": [{
      "type": "inAppPurchasePrices",
      "id": "'"${PRICE_TEMP_ID}"'",
      "relationships": {
        "inAppPurchasePricePoint": {
          "data": {"type": "inAppPurchasePricePoints", "id": "'"${PRICE_POINT_ID}"'"}
        }
      }
    }]
  }' > /dev/null
echo "✓ Price schedule created"

echo ""
echo "=== Configuration Complete ==="
echo "Product: ${PRODUCT_NAME}"
echo "Price: NT\$${TARGET_PRICE}"
echo "Base Territory: ${BASE_TERRITORY}"
```

### Troubleshooting Common Issues

#### Issue 1: 409 Conflict - Localization Already Exists
**Error:**
```json
{
  "errors": [{
    "status": "409",
    "code": "ENTITY_ALREADY_EXISTS"
  }]
}
```

**Solution:** Update existing localization instead of creating new one.

#### Issue 2: 404 Not Found - Invalid IAP ID
**Error:**
```json
{
  "errors": [{
    "status": "404",
    "code": "NOT_FOUND"
  }]
}
```

**Solution:** Verify IAP ID by listing all IAPs for your app first.

#### Issue 3: Products Show "MISSING_METADATA" Status

Even after API configuration, products may show "缺少元資料" (Missing Metadata) in App Store Connect.

**Possible causes:**
1. Missing review screenshot (must upload via web interface)
2. Missing app binary submission
3. Configuration not yet synced

**Solution:**
- Upload review screenshot via App Store Connect web interface
- First IAP must be submitted with app binary
- Wait a few minutes for API changes to sync
- Verify via Playwright or web browser that prices are actually set
- **Missing pricing** is the most common cause — localizations alone won't clear MISSING_METADATA

#### Issue 4: JWT Authentication Failures

**Error:**
```json
{
  "errors": [{
    "status": "401",
    "code": "NOT_AUTHORIZED"
  }]
}
```

**Solution:**
- Use Python with PyJWT library (more reliable than bash/OpenSSL)
- Ensure JWT hasn't expired (20 minute limit)
- Verify all three credentials are correct (Key ID, Issuer ID, Private Key)
- Check that private key file has correct permissions

#### Issue 5: Age Rating Declaration — Mixed Attribute Types

**Error:**
```
Unexpected json type for 'healthOrWellnessTopics'. Expected BOOLEAN but got STRING
You must provide a value for the attribute 'gunsOrOtherWeapons'
```

**Explanation:** Age rating attributes use TWO different types:

| Type | Attributes |
|------|-----------|
| **String enum** (`NONE` / `INFREQUENT_OR_MILD` / `FREQUENT_OR_INTENSE`) | `alcoholTobaccoOrDrugUseOrReferences`, `contests`, `gamblingSimulated`, `gunsOrOtherWeapons`, `horrorOrFearThemes`, `matureOrSuggestiveThemes`, `medicalOrTreatmentInformation`, `profanityOrCrudeHumor`, `sexualContentGraphicAndNudity`, `sexualContentOrNudity`, `violenceCartoonOrFantasy`, `violenceRealistic`, `violenceRealisticProlongedGraphicOrSadistic` |
| **Boolean** (`true` / `false`) | `gambling`, `unrestrictedWebAccess`, `lootBox`, `advertising`, `messagingAndChat`, `userGeneratedContent`, `parentalControls`, `ageAssurance`, `healthOrWellnessTopics` |

**Solution:** You MUST provide ALL attributes in a single PATCH call — the API requires them all at once and will reject partial updates. The fields `kidsAgeBand` and `developerAgeRatingInfoUrl` are optional and will remain `None`.

#### Issue 6: IAP/Subscription Description Character Limit

**Error:**
```json
{"code": "ENTITY_ERROR.ATTRIBUTE.INVALID.TOO_LONG"}
```

**Solution:** IAP and subscription localization `description` has a strict character limit (~45 characters). European language descriptions tend to be longer than CJK equivalents. Keep descriptions very short (e.g., "Ad-free, all features, forever" instead of "One-time purchase for lifetime ad-free experience and all premium features").

#### Issue 7: V1 vs V2 Endpoint for IAP Localizations

**Error:**
```json
{"code": "PATH_ERROR", "detail": "The relationship 'inAppPurchaseLocalizations' does not exist"}
```

**Explanation:** When creating IAP localizations, the `POST` creates via V1, but listing them requires the **V2** endpoint:
- ❌ `GET /v1/inAppPurchases/{id}/inAppPurchaseLocalizations` → 404 PATH_ERROR
- ✅ `GET /v2/inAppPurchases/{id}/inAppPurchaseLocalizations` → 200 OK

**Solution:** Always use the V2 endpoint to list/verify IAP localizations:
```python
r = requests.get(f"https://api.appstoreconnect.apple.com/v2/inAppPurchases/{iap_id}/inAppPurchaseLocalizations", headers=HEADERS)
```

#### Issue 8: Inline Entity ID Format for Price Schedules

**Error:**
```json
{"code": "ENTITY_ERROR.INCLUDED.INVALID_ID", "detail": "The provided included entity id has invalid format. For inline creation..."}
```

**Solution:** When creating `inAppPurchasePriceSchedules` with inline `inAppPurchasePrices`, the temporary ID must use `${uuid}` format:
```python
import uuid
temp_id = "${" + str(uuid.uuid4()) + "}"
# Use this temp_id in both data.relationships.manualPrices[].id and included[].id
```

#### Issue 9: IAP Price Points — V2 Endpoint Required

**Error:**
```json
{"code": "FORBIDDEN_ERROR", "detail": "The resource 'inAppPurchasePricePoints' has no allowed operations"}
```

**Explanation:** Finding price points for IAP and subscriptions requires different endpoints:
- **IAP Non-Consumable**: `GET /v2/inAppPurchases/{id}/pricePoints?filter[territory]=TWN` (NOT v1)
- **Subscriptions**: `GET /v1/subscriptions/{id}/pricePoints?filter[territory]=TWN`
- ❌ `GET /v1/inAppPurchasePricePoints` → 403 FORBIDDEN
- ❌ `GET /v1/subscriptionPricePoints` → 403 FORBIDDEN (only GET_INSTANCE allowed, not collection)

**Tip:** Price point lists are very long (~800 items). Use `limit=200` and paginate to find your target price.

#### Issue 10: Release Notes (What's New) Emoji Rejection

**Error:**
```json
{
  "code": "ENTITY_ERROR.ATTRIBUTE.INVALID",
  "detail": "An attribute value has invalid characters. - What's New in This Version can’t contain the following character(s): 🧹, 🖼, 🛠, ️"
}
```

**Explanation:** Apple's App Store Connect API strictly rejects many unicode emojis and variation selectors in the `release_notes.txt` (What's New in This Version) when uploading metadata via Fastlane or API. This usually happens when the same emoji-filled text works perfectly fine for Google Play, but Apple rejects it. Common culprits include: 🔗, 💬, 🌍, 🧹, 🖼, 🛠, ⚡️, ✨, 🚀, 🐛, 🎨, ⚙️, 💡, 📍.

**Solution:**
When uploading App Store metadata with `fastlane deliver`, you must strip emojis from the `release_notes.txt` of **all locales**. Do not just remove the specific ones in the error message, as there might be others it will reject on the next try.

Here is a Python script to quickly clean release notes in a fastlane project:

```python
import glob
import re

files = glob.glob('ios/fastlane/metadata/*/release_notes.txt')
for fpath in files:
    with open(fpath, 'r', encoding='utf-8') as f:
        content = f.read()
    
    # Remove common emojis and the invisible variation selector (\ufe0f)
    cleaned = re.sub(r'[🔗💬🌍🧹🖼🛠⚡✨🚀🐛🎨⚙💡📍]', '', content)
    cleaned = cleaned.replace('\ufe0f', '')
    
    if cleaned != content:
        with open(fpath, 'w', encoding='utf-8') as f:
            f.write(cleaned)
        print(f"Cleaned {fpath}")
```

### Example: App Configuration

Here's an example configuration for an app with multiple subscription tiers:

```bash
# App ID: <YOUR_APP_ID>
# Bundle ID: com.example.yourapp

# Products configured:
# 1. Monthly Subscription (<YOUR_IAP_ID_MONTHLY>): NT$140 (tier 10039)
# 2. Yearly Subscription (<YOUR_IAP_ID_YEARLY>): NT$990 (tier 10190)
# 3. Lifetime Membership (<YOUR_IAP_ID>): NT$2990 (tier 10363)

# All products:
# - Localized in zh-Hant (Traditional Chinese)
# - Available in Taiwan (TWN) as base territory
# - Prices automatically calculated for 175 territories
# - RevenueCat entitlement: "premium"
```


---

## App Store Server API (Purchase Validation)

Different from App Store Connect API. Used for transaction validation.

Base URL: `https://api.storekit.itunes.apple.com/inApps/v1`

### Validate Transaction
```bash
curl -s 'https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

### Get Subscription Status
```bash
curl -s 'https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{transactionId}' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

### Get Transaction History
```bash
curl -s 'https://api.storekit.itunes.apple.com/inApps/v1/history/{transactionId}' \
  -H "Authorization: Bearer $ASC_TOKEN"
```

---

## Auto-Renewable Subscriptions Complete Guide

This section covers the specific APIs for managing auto-renewable subscriptions, which differ from regular IAP products.

### Subscription State Values

| State | Description |
|-------|-------------|
| `MISSING_METADATA` | Incomplete configuration - check localization, availability, prices |
| `READY_TO_SUBMIT` | Fully configured, can be submitted with app |
| `WAITING_FOR_REVIEW` | Submitted, pending Apple review |
| `APPROVED` | Approved and live |
| `DEVELOPER_REMOVED_FROM_SALE` | Removed by developer |

**IMPORTANT**: Subscriptions remain in `MISSING_METADATA` until the app binary is first submitted. This is expected behavior - they are still usable in sandbox testing.

### 1. Subscription Localizations

**Create Subscription Localization:**
```python
# Critical: Must match app's primary locale!
data = {
    "data": {
        "type": "subscriptionLocalizations",
        "attributes": {
            "locale": "zh-Hant",  # Use zh-Hant, NOT zh-TW!
            "name": "專業版 (月付)",
            "description": "每月訂閱，享有無限功能"  # Max 55 characters!
        },
        "relationships": {
            "subscription": {
                "data": {"type": "subscriptions", "id": "{subscription_id}"}
            }
        }
    }
}
resp = requests.post(f"{BASE_URL}/subscriptionLocalizations", headers=headers, json=data)
```

**Important Notes:**
- Description has **55 character limit**
- Must create localization for app's **primary locale** (check `apps/{id}` → `primaryLocale`)
- Common locales: `en-US`, `zh-Hant` (Traditional Chinese), `zh-Hans` (Simplified Chinese)

### 2. Subscription Availability

**Create Availability for All Territories:**
```python
# First get all territories
resp = requests.get(f"{BASE_URL}/territories?limit=200", headers=headers)
territories = resp.json().get("data", [])
territory_data = [{"type": "territories", "id": t["id"]} for t in territories]

# Create availability
data = {
    "data": {
        "type": "subscriptionAvailabilities",
        "attributes": {
            "availableInNewTerritories": True
        },
        "relationships": {
            "subscription": {
                "data": {"type": "subscriptions", "id": "{subscription_id}"}
            },
            "availableTerritories": {
                "data": territory_data  # Array of all territories
            }
        }
    }
}
resp = requests.post(f"{BASE_URL}/subscriptionAvailabilities", headers=headers, json=data)
```

### 3. Subscription Group Localizations

**Create Group Localization:**
```python
data = {
    "data": {
        "type": "subscriptionGroupLocalizations",
        "attributes": {
            "locale": "zh-Hant",
            "name": "專業版訂閱"
        },
        "relationships": {
            "subscriptionGroup": {
                "data": {"type": "subscriptionGroups", "id": "{group_id}"}
            }
        }
    }
}
resp = requests.post(f"{BASE_URL}/subscriptionGroupLocalizations", headers=headers, json=data)
```

### 4. Subscription Pricing

> [!CAUTION]
> **NEVER SKIP THIS STEP.** NEVER tell the user "pricing needs to be done manually on the web."
> The complete working API method (PATCH + equalizations) is documented below. USE IT.
> This is the #1 most commonly skipped step — do NOT repeat this mistake.

> **IMPORTANT**: `POST /v1/subscriptionPrices` returns `500 UNEXPECTED_ERROR` when creating
> initial subscription prices. Use the **PATCH + equalizations** approach below instead.

**Complete Subscription Pricing (PATCH + Equalizations — Working Method):**

```python
import requests, json

def set_subscription_price(sub_id, target_usd, headers):
    """Set subscription price for all territories using PATCH + equalizations.
    
    This is the ONLY reliable method. POST /subscriptionPrices returns 500 for initial prices.
    
    Steps:
    1. Find the price point ID for the target USD price
    2. Get equalized price points for all other territories
    3. PATCH the subscription with inline prices for all territories
    """
    BASE_URL = "https://api.appstoreconnect.apple.com/v1"
    
    # Step 1: Find USA price point for target price (paginate — Apple has 400+ tiers)
    all_points = []
    url = f"{BASE_URL}/subscriptions/{sub_id}/pricePoints"
    params = {"filter[territory]": "USA", "limit": 200}
    while url:
        resp = requests.get(url, headers=headers, params=params)
        data = resp.json()
        all_points.extend(data.get("data", []))
        next_url = data.get("links", {}).get("next")
        if next_url and next_url != url:
            url = next_url
            params = {}
        else:
            break

    base_pp_id = None
    for pt in all_points:
        cp = float(pt["attributes"].get("customerPrice", 0))
        if abs(cp - target_usd) < 0.01:
            base_pp_id = pt["id"]
            break

    if not base_pp_id:
        raise ValueError(f"No price point found for ${target_usd}")

    # Step 2: Get equalizations (Apple auto-calculates equivalent prices for all territories)
    eq_prices = []
    url = f"{BASE_URL}/subscriptionPricePoints/{base_pp_id}/equalizations"
 

…(truncated)
