TL;DR
I added a Tip Jar feature to my iOS app. The app side was implemented with SwiftUI + StoreKit 2, and the App Store Connect REST API was used to complete product registration, localization, pricing, review screenshots, territory availability, and TestFlight distribution entirely from the command line. This article documents the full procedure in a reproducible manner.
Prerequisites: This is a follow-up to Complete Guide to Submitting an iOS App for Review Using Only the App Store Connect API. It assumes you have already set up API key retrieval and JWT generation.
Overall Flow
- App-side implementation (StoreKit 2 + SwiftUI)
- Register products via App Store Connect API (3 consumable items)
- Localization setup (Japanese and English)
- Pricing ($0.99 / $2.99 / $6.99)
- Upload review screenshots
- Territory availability settings
- Paid Apps agreement
- Verify with TestFlight
1. App-Side Implementation
1.1 StoreKit Configuration File
Create TipJar.storekit for Xcode’s test environment. This enables StoreKit testing in the simulator.
{
"products" : [
{
"displayPrice" : "0.99",
"familyShareable" : false,
"internalID" : "tip_small_001",
"localizations" : [
{
"description" : "A small tip to support development",
"displayName" : "Small Tip",
"locale" : "ja"
},
{
"description" : "A small tip to support development",
"displayName" : "Small Tip",
"locale" : "en_US"
}
],
"productID" : "com.example.app.tip.small",
"referenceName" : "Small Tip",
"type" : "Consumable"
}
]
}
If you are using XcodeGen, add the StoreKit configuration to the scheme in project.yml:
schemes:
MyApp:
run:
config: Debug
storeKitConfiguration: MyApp/TipJar.storekit
1.2 TipJarManager (StoreKit 2)
import Foundation
import StoreKit
@MainActor
class TipJarManager: ObservableObject {
static let productIDs: [String] = [
"com.example.app.tip.small",
"com.example.app.tip.medium",
"com.example.app.tip.large"
]
@Published var products: [Product] = []
@Published var isLoading = true
@Published var purchaseState: PurchaseState = .idle
enum PurchaseState: Equatable {
case idle, purchasing, success
case error(String)
}
func loadProducts() async {
isLoading = true
do {
let storeProducts = try await Product.products(for: Self.productIDs)
products = storeProducts.sorted { $0.price < $1.price }
} catch {
products = []
}
isLoading = false
}
func purchase(_ product: Product) async {
purchaseState = .purchasing
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
switch verification {
case .verified(let transaction):
await transaction.finish()
purchaseState = .success
case .unverified:
purchaseState = .error("Purchase verification failed")
}
case .userCancelled, .pending:
purchaseState = .idle
@unknown default:
purchaseState = .idle
}
} catch {
purchaseState = .error(error.localizedDescription)
}
}
}
1.3 TipJarView (SwiftUI)
import SwiftUI
import StoreKit
struct TipJarView: View {
@StateObject private var manager = TipJarManager()
var body: some View {
List {
Section {
VStack(spacing: 8) {
Text("Support This App")
.font(.title2).bold()
Text("Please consider leaving a tip to support development.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.listRowBackground(Color.clear)
}
Section {
if manager.isLoading {
ProgressView().frame(maxWidth: .infinity)
} else {
ForEach(manager.products, id: \.id) { product in
Button {
Task { await manager.purchase(product) }
} label: {
HStack {
VStack(alignment: .leading) {
Text(product.displayName).font(.body)
Text(product.description)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(product.displayPrice)
.font(.headline)
.foregroundColor(.accentColor)
}
}
}
}
}
if case .success = manager.purchaseState {
Section {
HStack {
Spacer()
VStack {
Image(systemName: "heart.fill")
.font(.system(size: 32))
.foregroundColor(.pink)
Text("Thank you!")
.font(.headline)
}
Spacer()
}
}
}
}
.navigationTitle("Tip Jar")
.task { await manager.loadProducts() }
}
}
1.4 Transaction Listener
Set up a transaction listener in App’s init():
@main
struct MyApp: App {
private var transactionListener: Task<Void, Never>?
init() {
transactionListener = Task.detached {
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
}
}
}
}
// ...
}
2. JWT Generation (Shared Function)
Common token generation code used for all subsequent API calls:
import jwt, time, requests, json
KEY_ID = 'YOUR_KEY_ID'
ISSUER_ID = 'YOUR_ISSUER_ID'
P8_PATH = '~/.private_keys/AuthKey_YOUR_KEY_ID.p8'
def get_token():
with open(P8_PATH, 'r') as f:
private_key = f.read()
payload = {
'iss': ISSUER_ID,
'iat': int(time.time()),
'exp': int(time.time()) + 1200,
'aud': 'appstoreconnect-v1'
}
return jwt.encode(payload, private_key, algorithm='ES256',
headers={'kid': KEY_ID, 'typ': 'JWT'})
def headers(content_type=False):
h = {'Authorization': f'Bearer {get_token()}'}
if content_type:
h['Content-Type'] = 'application/json'
return h
3. Retrieving the App ID
r = requests.get(
'https://api.appstoreconnect.apple.com/v1/apps',
params={'filter[bundleId]': 'com.example.app', 'fields[apps]': 'name,bundleId'},
headers=headers()
)
app_id = r.json()['data'][0]['id']
print(f"App ID: {app_id}")
4. Creating Consumable Items
Create three tip items:
products = [
('com.example.app.tip.small', 'Small Tip'),
('com.example.app.tip.medium', 'Medium Tip'),
('com.example.app.tip.large', 'Large Tip'),
]
for product_id, name in products:
body = {
'data': {
'type': 'inAppPurchases',
'attributes': {
'productId': product_id,
'name': name,
'inAppPurchaseType': 'CONSUMABLE',
},
'relationships': {
'app': {
'data': {'type': 'apps', 'id': app_id}
}
}
}
}
r = requests.post(
'https://api.appstoreconnect.apple.com/v2/inAppPurchases',
headers=headers(content_type=True),
json=body
)
print(f"{name}: {r.status_code} - ID: {r.json()['data']['id']}")
Note: The
referenceNameattribute is not available in the v2 API. Usenameonly.
5. Localization Setup
Add Japanese and English localizations for each product:
localizations = {
'IAP_ID_SMALL': [
{'locale': 'en-US', 'name': 'Small Tip',
'description': 'A small tip to support development'},
{'locale': 'ja', 'name': '小さな応援',
'description': '開発を応援する小さなチップ'},
],
'IAP_ID_MEDIUM': [
{'locale': 'en-US', 'name': 'Medium Tip',
'description': 'A tip to support development'},
{'locale': 'ja', 'name': '応援',
'description': '開発を応援するチップ'},
],
'IAP_ID_LARGE': [
{'locale': 'en-US', 'name': 'Large Tip',
'description': 'A generous tip to support development'},
{'locale': 'ja', 'name': '大きな応援',
'description': '開発を大きく応援するチップ'},
],
}
for iap_id, locs in localizations.items():
for loc in locs:
body = {
'data': {
'type': 'inAppPurchaseLocalizations',
'attributes': {
'locale': loc['locale'],
'name': loc['name'],
'description': loc['description'],
},
'relationships': {
'inAppPurchaseV2': {
'data': {'type': 'inAppPurchases', 'id': iap_id}
}
}
}
}
r = requests.post(
'https://api.appstoreconnect.apple.com/v1/inAppPurchaseLocalizations',
headers=headers(content_type=True),
json=body
)
print(f" {loc['locale']}: {r.status_code}")
6. Setting Prices
Price point IDs are composed of Base64-encoded JSON:
import base64
def make_price_point_id(iap_id, territory, price_tier):
"""Build a price point ID"""
data = json.dumps(
{"s": iap_id, "t": territory, "p": price_tier},
separators=(',', ':')
)
return base64.b64encode(data.encode()).decode().rstrip('=')
First, check the price points to identify the tier corresponding to the desired price:
# Get price tiers for $0.99, $2.99, $6.99
r = requests.get(
f'https://api.appstoreconnect.apple.com/v2/inAppPurchases/{iap_id}/pricePoints',
headers=headers(),
params={'filter[territory]': 'USA', 'limit': 200}
)
for pp in r.json()['data']:
price = pp['attributes']['customerPrice']
if price in ['0.99', '2.99', '6.99']:
print(f"${price} -> tier from ID: {pp['id']}")
The actual pricing is done using iapPriceSchedule:
# Price tiers: $0.99=10010, $2.99=10036, $6.99=10088
price_configs = [
{'iap_id': 'IAP_ID_SMALL', 'tier': '10010'}, # $0.99
{'iap_id': 'IAP_ID_MEDIUM', 'tier': '10036'}, # $2.99
{'iap_id': 'IAP_ID_LARGE', 'tier': '10088'}, # $6.99
]
for config in price_configs:
pp_id = make_price_point_id(config['iap_id'], 'USA', config['tier'])
body = {
'data': {
'type': 'inAppPurchasePriceSchedules',
'relationships': {
'inAppPurchase': {
'data': {'type': 'inAppPurchases', 'id': config['iap_id']}
},
'baseTerritory': {
'data': {'type': 'territories', 'id': 'USA'}
},
'manualPrices': {
'data': [{'type': 'inAppPurchasePrices', 'id': '${price1}'}]
}
}
},
'included': [{
'type': 'inAppPurchasePrices',
'id': '${price1}',
'relationships': {
'inAppPurchasePricePoint': {
'data': {'type': 'inAppPurchasePricePoints', 'id': pp_id}
}
}
}]
}
r = requests.post(
'https://api.appstoreconnect.apple.com/v1/inAppPurchasePriceSchedules',
headers=headers(content_type=True),
json=body
)
print(f"Price set: {r.status_code}")
7. Uploading Review Screenshots
Attach a review screenshot to each product. This is done in three steps:
import hashlib, os
screenshot_path = 'tipjar_screenshot.png'
file_size = os.path.getsize(screenshot_path)
with open(screenshot_path, 'rb') as f:
file_data = f.read()
md5 = hashlib.md5(file_data).hexdigest()
for iap_id in iap_ids:
# Step 1: Create a reservation
body = {
'data': {
'type': 'inAppPurchaseAppStoreReviewScreenshots',
'attributes': {
'fileName': 'tipjar.png',
'fileSize': file_size,
},
'relationships': {
'inAppPurchaseV2': {
'data': {'type': 'inAppPurchases', 'id': iap_id}
}
}
}
}
r = requests.post(
'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots',
headers=headers(content_type=True),
json=body
)
data = r.json()['data']
ss_id = data['id']
ops = data['attributes']['uploadOperations']
# Step 2: Upload chunks
for op in ops:
chunk = file_data[op['offset']:op['offset'] + op['length']]
hdrs = {h['name']: h['value'] for h in op['requestHeaders']}
requests.put(op['url'], headers=hdrs, data=chunk)
# Step 3: Commit the upload
commit = {
'data': {
'type': 'inAppPurchaseAppStoreReviewScreenshots',
'id': ss_id,
'attributes': {
'uploaded': True,
'sourceFileChecksum': md5,
}
}
}
requests.patch(
f'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAppStoreReviewScreenshots/{ss_id}',
headers=headers(content_type=True),
json=commit
)
print(f"Screenshot uploaded for {iap_id}")
8. Territory Availability Settings
Make the products available in all territories:
# Fetch all territories
r = requests.get(
'https://api.appstoreconnect.apple.com/v1/territories',
headers=headers(),
params={'limit': 200}
)
territory_data = [{'type': 'territories', 'id': t['id']} for t in r.json()['data']]
for iap_id in iap_ids:
body = {
'data': {
'type': 'inAppPurchaseAvailabilities',
'attributes': {'availableInNewTerritories': True},
'relationships': {
'inAppPurchase': {
'data': {'type': 'inAppPurchases', 'id': iap_id}
},
'availableTerritories': {
'data': territory_data
}
}
}
}
r = requests.post(
'https://api.appstoreconnect.apple.com/v1/inAppPurchaseAvailabilities',
headers=headers(content_type=True),
json=body
)
print(f"Availability set: {r.status_code}")
At this point, the status of each product should be READY_TO_SUBMIT.
9. TestFlight Distribution
9.1 Creating an App Store Version
To test IAPs, you need to create an App Store version and associate a build with it:
body = {
'data': {
'type': 'appStoreVersions',
'attributes': {
'versionString': '1.1.0',
'platform': 'IOS',
'releaseType': 'MANUAL',
},
'relationships': {
'app': {
'data': {'type': 'apps', 'id': app_id}
},
'build': {
'data': {'type': 'builds', 'id': build_id}
}
}
}
}
r = requests.post(
'https://api.appstoreconnect.apple.com/v1/appStoreVersions',
headers=headers(content_type=True),
json=body
)
9.2 Export Compliance Settings
An encryption declaration is required to enable TestFlight testing:
body = {
'data': {
'type': 'builds',
'id': build_id,
'attributes': {
'usesNonExemptEncryption': False
}
}
}
requests.patch(
f'https://api.appstoreconnect.apple.com/v1/builds/{build_id}',
headers=headers(content_type=True),
json=body
)
9.3 Creating a Beta Group and Adding Testers
# Create a beta group
body = {
'data': {
'type': 'betaGroups',
'attributes': {
'name': 'Internal Testers',
'isInternalGroup': True,
},
'relationships': {
'app': {
'data': {'type': 'apps', 'id': app_id}
}
}
}
}
r = requests.post(
'https://api.appstoreconnect.apple.com/v1/betaGroups',
headers=headers(content_type=True),
json=body
)
group_id = r.json()['data']['id']
# Add a tester
body = {
'data': {
'type': 'betaTesters',
'attributes': {
'firstName': 'Taro',
'lastName': 'Yamada',
'email': 'taro@example.com',
},
'relationships': {
'betaGroups': {
'data': [{'type': 'betaGroups', 'id': group_id}]
}
}
}
}
requests.post(
'https://api.appstoreconnect.apple.com/v1/betaTesters',
headers=headers(content_type=True),
json=body
)
# Add the build to the group
body = {
'data': [{'type': 'builds', 'id': build_id}]
}
requests.post(
f'https://api.appstoreconnect.apple.com/v1/betaGroups/{group_id}/relationships/builds',
headers=headers(content_type=True),
json=body
)
10. Paid Apps Agreement
To make IAPs functional, you must complete the Paid Apps agreement in App Store Connect. This cannot be done via the API and must be configured manually in the browser.
Go to App Store Connect > Business and complete the following:
- Agree to the terms and conditions
- Register a bank account — Add a bank account for payouts (a major bank is recommended for international transfers)
- Submit tax forms:
- U.S. Form W-8BEN — A withholding tax reduction request filed by individual developers residing in Japan
- By applying Article 12(1) of the Japan-U.S. Tax Treaty, the withholding rate is reduced from 30% to 0%
- U.S. Certificate of Foreign Status of Beneficial Owner — Certification that the beneficial owner resides outside the U.S.
- U.S. Form W-8BEN — A withholding tax reduction request filed by individual developers residing in Japan
Once all statuses show as “Active”, IAPs will work in TestFlight, Sandbox, and production.
Summary
| Task | Method |
|---|---|
| Product creation | API (POST /v2/inAppPurchases) |
| Localization | API (POST /v1/inAppPurchaseLocalizations) |
| Pricing | API (POST /v1/inAppPurchasePriceSchedules) |
| Screenshots | API (3-step: reserve, upload, commit) |
| Territory availability | API (POST /v1/inAppPurchaseAvailabilities) |
| TestFlight distribution | API (create version, export compliance, beta group) |
| Paid Apps agreement | Manual (bank account and tax forms) |
Since the Tip Jar feature uses Consumable IAPs, there is no need for the complex management required by subscriptions. By combining StoreKit 2’s straightforward API with the App Store Connect REST API, almost all of the work can be completed from the command line.
Note: In the StoreKit test environment (Xcode’s
.storekitfile), localizations may not be reflected correctly. In production, the localizations configured in App Store Connect will be applied, so verify using TestFlight or the live environment.