Apple In-App Purchase Setup
This guide covers setting up Apple In-App Purchases (IAP) using StoreKit for iOS and macOS applications.
Prerequisites
- Apple Developer account ($99/year)
- App registered in App Store Connect
- Access to App Store Connect
- Paid Applications agreement completed
Step 1: Complete Agreements
- Go to App Store Connect
- Click Agreements, Tax, and Banking
- Complete the Paid Applications agreement
- Add bank account and tax information
Important: You cannot test purchases until agreements are completed.
Step 2: Create In-App Purchases
- Go to your app in App Store Connect
- Navigate to Features > In-App Purchases
- Click + to add a new product
Product Types
| Type | Description | Use Case |
|---|---|---|
| Consumable | Can be purchased multiple times | Virtual currency, lives |
| Non-Consumable | One-time purchase | Premium unlock, remove ads |
| Auto-Renewable Subscription | Recurring billing | Monthly/yearly membership |
| Non-Renewing Subscription | Fixed-period access | Season pass |
Create a Subscription
- Select Auto-Renewable Subscription
- Create a Subscription Group:
- Group name: "Pro Plans"
- Reference name: Internal identifier
- Add subscription details:
| Field | Description | Example |
|---|---|---|
| Reference Name | Internal name | Pro Monthly |
| Product ID | Unique identifier | com.app.pro.monthly |
| Subscription Duration | Billing period | 1 Month |
| Price | Subscription price | $9.99 |
- Add localization:
- Display Name
- Description
- Click Save
Configure Subscription Features
- Free Trial: Optional free trial period
- Introductory Offer: Discounted first period
- Promotional Offer: Special pricing for existing users
- Offer Codes: Redeemable codes for free access
Step 3: Configure Server Notifications
App Store Server Notifications V2:
- In App Store Connect, go to App Information
- Find App Store Server Notifications
- Enter your server URL:
https://yourdomain.com/v1/payment/webhook/apple
- Select Version 2 (recommended)
- Save changes
Notification Types
| Type | Description |
|---|---|
| SUBSCRIBED | New subscription |
| DID_RENEW | Subscription renewed |
| DIDCHANGERENEWAL_PREF | Plan change scheduled |
| EXPIRED | Subscription expired |
| DIDFAILTO_RENEW | Payment failed |
| REFUND | Purchase refunded |
Step 4: Set Up Shared Secret
For receipt validation:
- Go to your app in App Store Connect
- Navigate to App Information
- Find App-Specific Shared Secret
- Click Manage and generate a secret
- Copy and save the shared secret securely
Step 5: Configure in OpenDev
- Log in to OpenDev Platform
- Go to your application's Payment Configuration
- Add Apple configuration:
{
"platform": "apple",
"enabled": true,
"config": {
"bundleId": "com.yourcompany.myapp",
"sharedSecret": "your_shared_secret",
"environment": "production",
"notificationUrl": "https://yourdomain.com/v1/payment/webhook/apple"
}
}
Configuration Fields
| Field | Required | Description |
|---|---|---|
| Bundle ID | Yes | Your app's bundle identifier |
| Shared Secret | Yes | App-specific shared secret |
| Environment | Yes | sandbox or production |
| Notification URL | Yes | Server notification endpoint |
Step 6: Configure Product Tiers
Link Apple Product IDs:
- Go to Product Tiers in OpenDev
- For each tier, add Apple product ID:
{
"productId": "pro_monthly",
"name": "Pro Monthly",
"platformProductIds": {
"apple": "com.yourcompany.myapp.pro.monthly"
}
}
Step 7: Implement StoreKit 2
Add StoreKit Capability
- Open Xcode project
- Select target > Signing & Capabilities
- Click + Capability
- Add In-App Purchase
Fetch Products
import StoreKit
class StoreManager: ObservableObject {
@Published var products: [Product] = []
func fetchProducts() async {
do {
let productIds = ["com.app.pro.monthly", "com.app.pro.yearly"]
products = try await Product.products(for: productIds)
} catch {
print("Failed to fetch products: \(error)")
}
}
}
Purchase Product
func purchase(_ product: Product) async throws -> Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
// Send to server for verification
await sendToServer(transaction: transaction)
// Finish the transaction
await transaction.finish()
return transaction
case .pending:
// Transaction pending (e.g., Ask to Buy)
return nil
case .userCancelled:
return nil
@unknown default:
return nil
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let signedType):
return signedType
}
}
Handle Transactions
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
// Update entitlements
await self.updateEntitlements(transaction)
await transaction.finish()
} catch {
print("Transaction verification failed")
}
}
}
}
Restore Purchases
func restorePurchases() async {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
// Restore entitlement
await updateEntitlements(transaction)
}
}
}
Step 8: Server-Side Verification
Verify Receipt (Legacy)
const axios = require('axios');
async function verifyReceipt(receiptData, isProduction = true) {
const url = isProduction
? 'https://buy.itunes.apple.com/verifyReceipt'
: 'https://sandbox.itunes.apple.com/verifyReceipt';
const response = await axios.post(url, {
'receipt-data': receiptData,
'password': process.env.APPLE_SHARED_SECRET,
});
return response.data;
}
Verify with App Store Server API (Recommended)
const { SignedDataVerifier } = require('@apple/app-store-server-library');
async function verifyTransaction(signedTransaction) {
const verifier = new SignedDataVerifier(
appleRootCAs,
true, // Enable online checks
Environment.PRODUCTION,
bundleId
);
const transaction = await verifier.verifyAndDecodeTransaction(signedTransaction);
return transaction;
}
Step 9: Handle Notifications
const { SignedDataVerifier } = require('@apple/app-store-server-library');
app.post('/webhook/apple', async (req, res) => {
try {
const verifier = new SignedDataVerifier(/*...*/);
const notification = await verifier.verifyAndDecodeNotification(
req.body.signedPayload
);
switch (notification.notificationType) {
case 'SUBSCRIBED':
await handleNewSubscription(notification);
break;
case 'DID_RENEW':
await handleRenewal(notification);
break;
case 'EXPIRED':
case 'DID_FAIL_TO_RENEW':
await handleExpiration(notification);
break;
case 'REFUND':
await handleRefund(notification);
break;
}
res.status(200).send();
} catch (error) {
res.status(400).send();
}
});
Step 10: Test the Integration
Sandbox Testing
- Create Sandbox tester in App Store Connect:
- Go to Users and Access > Sandbox > Testers
- Add test accounts
- On device:
- Sign out of regular App Store account
- Use Sandbox account for testing
- Purchases are free in sandbox
Test Scenarios
| Scenario | How to Test |
|---|---|
| New purchase | Buy with sandbox account |
| Subscription renewal | Sandbox renews faster (see below) |
| Failed payment | Interrupt transaction |
| Restore purchases | Delete and reinstall app |
Sandbox Time Acceleration
| Real Duration | Sandbox Duration |
|---|---|
| 1 week | 3 minutes |
| 1 month | 5 minutes |
| 2 months | 10 minutes |
| 1 year | 1 hour |
Troubleshooting
Products Not Loading
Solutions:
- Ensure Paid Applications agreement is complete
- Product must be in "Ready to Submit" state
- Bundle ID must match exactly
- Wait 15-30 minutes after creating products
Purchase Not Completing
Solutions:
- Check sandbox account is valid
- Verify In-App Purchase capability is enabled
- Check for parental controls or restrictions
Server Notifications Not Received
Solutions:
- Verify URL is HTTPS and publicly accessible
- Check URL returns 200 status
- Verify using correct notification version
Security Best Practices
- Verify on Server - Never trust client-side verification alone
- Use Server Notifications - For reliable subscription status
- Store Transaction IDs - Prevent duplicate processing
- Handle Grace Period - Don't immediately revoke access
- Secure Shared Secret - Never expose in client code