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:
- This skill (apple-appstore-manager) → Create ASC subscriptions + pricing
- google-play-manager → Create Google Play subscriptions + basePlans + activate
- 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
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):
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:
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:
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
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 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:
# 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
UNIVERSALplatform (check viaGET /v1/bundleIds?filter[identifier]=...) - If Bundle ID is
IOSonly, you need to create a new Bundle ID or update it in the Developer Portal
Check Bundle ID platform:
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.
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:
# 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 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:
# 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 .app3rd Party Mac Developer Installer: <name> (<TEAM_ID>)— signs the .pkg
Alternative: Manual Re-sign After Flutter Build:
# 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
# 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
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:
<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
curl -s 'https://api.appstoreconnect.apple.com/v1/apps' \
-H "Authorization: Bearer $ASC_TOKEN"
2. Get App Details
curl -s 'https://api.appstoreconnect.apple.com/v1/apps/{app_id}' \
-H "Authorization: Bearer $ASC_TOKEN"
3. List In-App Purchases for App
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/inAppPurchasesreturns403 FORBIDDENfor consumables. Use the v2 endpoint instead:POST /v2/inAppPurchases.
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
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
curl -s -X DELETE 'https://api.appstoreconnect.apple.com/v1/inAppPurchases/{iap_id}' \
-H "Authorization: Bearer $ASC_TOKEN"
7. List Subscription Groups
curl -s 'https://api.appstoreconnect.apple.com/v1/apps/{app_id}/subscriptionGroups' \
-H "Authorization: Bearer $ASC_TOKEN"
8. Create Subscription Group
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
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
- Get the IAP product ID from your app
- Configure localization (display name, description)
- Set availability (territories)
- Configure pricing and price schedule
- Verify configuration via API or web interface
Step 1: Get IAP Product IDs
List all IAP products for an app to get their IDs:
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:
{
"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:
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-Hantfor Traditional Chinese (notzh-TW) - Use
zh-Hansfor 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:
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- TaiwanUSA- United StatesCHN- ChinaJPN- 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:
{
"s": "IAP_PRODUCT_ID",
"t": "TERRITORY_CODE",
"p": "PRICE_TIER"
}
Example: eyJzIjoiNjc1NjQ4MzMyOCIsInQiOiJUV04iLCJwIjoiMTAzNjMifQ
Decodes to:
{
"s": "<YOUR_IAP_ID>",
"t": "TWN",
"p": "10363"
}
4.2 Find Price Points for a Territory
Method 1: Direct Query (Fast)
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)
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:
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 relationshipsincluded: Contains the actual price point mapping- Temporary ID in
manualPrices.data[].idmust matchincluded[].id - The API will automatically calculate equivalent prices for all other territories
4.5 Verify Price Configuration
Check if price schedule was created successfully:
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:
#!/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:
{
"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:
{
"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:
- Missing review screenshot (must upload via web interface)
- Missing app binary submission
- 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:
{
"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:
{"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:
{"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:
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:
{"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:
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:
{"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:
{
"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:
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:
# 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
curl -s 'https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}' \
-H "Authorization: Bearer $ASC_TOKEN"
Get Subscription Status
curl -s 'https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{transactionId}' \
-H "Authorization: Bearer $ASC_TOKEN"
Get Transaction History
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:
# 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:
# 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:
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/subscriptionPricesreturns500 UNEXPECTED_ERRORwhen creating initial subscription prices. Use the PATCH + equalizations approach below instead.
Complete Subscription Pricing (PATCH + Equalizations — Working Method):
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)