Working with In-App Purchases
1. Configuring Products in App Store Connect
| Product Type | Description | Notes |
|---|---|---|
| Consumable | Product that can be purchased multiple times | Must be consumed after purchase to allow re-purchase |
| Non-Consumable | Product that is purchased once and retained | Can be used indefinitely, not tied to user account |
| Auto-Renewable Subscription | Subscription that renews automatically | Requires a subscription group and can be managed in App Store Connect |
| Non-Renewing Subscription | Subscription that does not auto-renew | Must be managed manually by the app developer |
Note: Product identifiers must be unique and follow reverse domain name notation.
Warning: Once a product is created in App Store Connect, it cannot be deleted or modified.
| Field | Value | Description |
|---|---|---|
| Product ID | com.example.myapp.premium |
Unique identifier for the product in App Store Connect |
| Reference Name | Premium Access |
Internal name used in App Store Connect |
| Product Type | Non-Consumable |
Type of in-app purchase (e.g., Consumable, Non-Consumable) |
| Price Tier | $4.99 |
Price set in App Store Connect (can be localized) |
2. Using StoreKit for Purchases
Example: Requesting Product Information
import StoreKit
class ProductManager: ObservableObject {
@Published var products: [Product] = []
func fetchProducts() {
Task {
do {
let storeProducts = try await Product.fetch(
.subscription("premium_subscription")
)
self.products = storeProducts
} catch {
print("Failed to fetch products: \(error)")
}
}
}
}
| Property | Description | Notes |
|---|---|---|
| Product.id | String |
Unique identifier for the product in App Store Connect |
| Product.name | String |
Name of the product as displayed in App Store |
| Product.description | String |
Description of the product as displayed in App Store |
| Product.price | String |
Formatted price in the user's local currency |
| Product.priceLocale | Locale |
Locale used to format the price |
Example: Initiating a Purchase
import StoreKit
func purchase(_ product: Product) {
Task {
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
// Handle successful purchase
print("Purchase verified: \(verification)")
case .userCancelled:
print("User cancelled purchase")
case .pending:
print("Purchase is pending")
@unknown default:
print("Unknown purchase result")
}
} catch {
print("Purchase failed: \(error)")
}
}
}
Note: Always verify purchase receipts on your server for security.
Example: Handling Purchase Verification
import StoreKit
func verifyPurchase(_ verification: VerificationResult<Transaction>) {
guard case .success = verification else { return }
// Send receipt to your server for validation
Task {
let receipt = await Transaction.currentReceipt
// Upload receipt to your server for verification
}
}
3. Fetching Product Information
| Property | Description | Notes |
|---|---|---|
| productIdentifier | Unique identifier for the product | Must match the identifier in App Store Connect |
| localizedTitle | Title of the product in the user's language | Read-only property |
| localizedDescription | Description of the product in the user's language | Read-only property |
| price | Price of the product in local currency | Read-only property |
| currencyCode | ISO 4217 currency code (e.g., USD, EUR) | Read-only property |
Example: Fetching Product Information
import StoreKit
class ProductManager: ObservableObject {
@Published var products: [SKProduct] = []
func fetchProducts() {
let productIdentifiers = ["com.example.product1", "com.example.product2"]
let set = Set(productIdentifiers)
SKProductsRequest(productsRequest: set).start { [weak self] response in
guard let products = response.products else { return }
self?.products = products
}
}
}
Note: The
SKProductsRequest must be called from the main thread, and it's recommended to handle errors gracefully in production apps.
4. Processing Transactions
| Property | Description | Notes |
|---|---|---|
| transactionIdentifier | Unique identifier for the transaction | Used to identify and verify transactions |
| payment | Payment object associated with the transaction | Contains product identifier and quantity |
| transactionDate | Date and time when the transaction occurred | UTC date format |
| transactionState | Current state of the transaction | Can be .purchased, .failed, .restored, or .deferred |
Example: Handling Transaction States
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
print("Purchase successful: \(transaction.payment.productIdentifier)")
// Deliver content to user
case .failed:
print("Purchase failed: \(transaction.error?.localizedDescription ?? "")")
case .restored:
print("Purchase restored: \(transaction.payment.productIdentifier)")
case .deferred:
print("Purchase deferred")
default:
break
}
}
}
Note: Always verify transactions with Apple's servers before delivering content to ensure authenticity.
Warning: Never deliver content immediately after a purchase. Always validate the receipt on Apple's servers first.
5. Restoring Purchases
Restoring purchases allows users to recover previously bought in-app items on a new device or after reinstalling the app. This is essential for non-consumable and auto-renewable subscriptions.
| Method | Description | Notes |
|---|---|---|
| restoreCompletedTransactions() | Restores all completed transactions for the current user. | Must be called to restore non-consumable purchases and subscriptions. |
| restoreCompletedTransactions(with:) | Restores transactions with a specific receipt. | Useful for restoring purchases from a specific device or session. |
Example: Restoring Purchases
import StoreKit
class PurchaseManager: NSObject, SKPaymentTransactionObserver {
func restorePurchases() {
SKPaymentQueue.default().restoreCompletedTransactions()
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .restored:
// Handle restored purchase
print("Restored: \(transaction.payment.productIdentifier)")
case .failed:
// Handle failed restore
print("Restore failed: \(transaction.error?.localizedDescription ?? "")")
default:
break
}
}
}
}
Note: Users must be logged into their Apple ID to restore purchases. The system will automatically handle restoring all eligible purchases for the user's account.
Warning: Do not call
restoreCompletedTransactions() in a loop or repeatedly. Apple's StoreKit may reject repeated restore requests.
Key Points for Restore Purchases
- Only non-consumable and auto-renewable subscription purchases can be restored.
- Consumable purchases cannot be restored and must be repurchased.
- Restoring is handled through
SKPaymentQueue. - Implement
paymentQueue(_:updatedTransactions:)to handle restored transactions.
6. Implementing Subscription Management
Example: Requesting Subscription Products
import StoreKit
class SubscriptionManager: ObservableObject {
@Published var products: [Product] = []
func fetchProducts() async throws {
let products = try await Product.fetchAll()
self.products = products.filter { $0.type == .autoRenewable }
}
}
| Property | Description | Notes |
|---|---|---|
| productID | String |
Unique identifier for the subscription product |
| displayName | String |
Name of the subscription tier |
| description | String |
Human-readable description of the subscription |
| price | String |
Formatted price in user's local currency |
| type | Product.Type |
Subscription type (e.g., .autoRenewable) |
Example: Purchasing a Subscription
func purchaseSubscription(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let transaction):
// Handle successful purchase
await transaction.finish()
case .userCancelled:
// Handle user cancellation
break
default:
// Handle other cases
break
}
}
Note: Always call
transaction.finish() to complete the transaction after handling it.Example: Managing Auto-Renewal
func manageSubscription() {
Task {
do {
let updates = try await StoreKit.Transaction.currentEntitlements
for update in updates {
if update.productType == .autoRenewable {
// Check renewal status
let renewalInfo = update.renewalInfo
if renewalInfo.isInGracePeriod {
// Handle grace period logic
}
}
}
} catch {
print("Failed to fetch entitlements: \(error)")
}
}
}
Subscription Management Workflow
- Fetch available subscription products using
Product.fetchAll() - Display product details to the user
- Initiate purchase with
product.purchase() - Handle transaction result and call
transaction.finish() - Monitor entitlements for renewal status
7. Handling Receipt Validation
Example: Basic Receipt Validation
import StoreKit
class ReceiptValidator: ObservableObject {
@Published var receiptStatus: String = "Not validated"
func validateReceipt() {
guard let appStoreReceiptURL = Bundle.main.appStoreReceiptURL else {
receiptStatus = "No receipt found"
return
}
do {
let receiptData = try Data(contentsOf: appStoreReceiptURL)
let receiptString = receiptData.base64EncodedString()
// Send to your server for validation
// Example API call:
// sendToServer(receiptString)
receiptStatus = "Receipt validated successfully"
} catch {
receiptStatus = "Failed to read receipt: \(error.localizedDescription)"
}
}
}
| Property | Description | Notes |
|---|---|---|
| appStoreReceiptURL | URL to the app's receipt file | Available in iOS 2.0+ |
| base64EncodedString() | Encodes data to base64 string | Used for sending receipt to server |
Note: The receipt is automatically refreshed by the App Store when needed. You should validate receipts on your server to ensure they are legitimate.
Warning: Never validate receipts locally on the device. Always send to your server for validation to prevent tampering.
Receipt Validation Steps
- Retrieve the receipt file from
Bundle.main.appStoreReceiptURL - Read the receipt data as
Data - Encode the data to base64 string
- Send to your server for validation
- Handle the response from your server
8. Using Server-Side Receipt Verification
Example: Verifying Receipt with Server
import Foundation
struct ReceiptVerificationRequest: Codable {
let receiptData: String
let password: String?
}
struct ReceiptVerificationResponse: Codable {
let status: Int
let receipt: Receipt?
let latestReceipt: String?
}
struct Receipt: Codable {
let bundleId: String
let applicationVersion: String
let inAppPurchases: [InAppPurchase]?
}
struct InAppPurchase: Codable {
let productId: String
let transactionId: String
let originalTransactionId: String?
let purchaseDate: Date
let originalPurchaseDate: Date?
}
func verifyReceiptWithServer(
receiptData: Data,
appBundleId: String,
appVersion: String,
serverURL: URL,
completion: @escaping (Result<ReceiptVerificationResponse, Error>) -> Void
) {
guard let receiptString = receiptData.base64EncodedString().addingPercentEncoding(withAllowedCharacters: .alphanumerics) else {
completion(.failure(NSError(domain: "ReceiptEncodingError", code: 0)))
return
}
let request = ReceiptVerificationRequest(
receiptData: receiptString,
password: "your-shared-secret"
)
var urlRequest = URLRequest(url: serverURL)
urlRequest.httpMethod = "POST"
urlRequest.httpBody = try? JSONEncoder().encode(request)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let data = data else {
completion(.failure(NSError(domain: "NoDataError", code: 0)))
return
}
do {
let result = try JSONDecoder().decode(ReceiptVerificationResponse.self, from: data)
completion(.success(result))
} catch {
completion(.failure(error))
}
}
task.resume()
}
| Property | Description | Notes |
|---|---|---|
| receiptData | Base64 encoded receipt data from the device | Required for verification |
| password | Shared secret from App Store Connect (optional) | Used for auto-renewable subscriptions |
| status | Response status code from Apple's server | 0 = success, other values indicate errors |
| receipt | Decoded receipt object with purchase details | Contains in-app purchases, bundle ID, version |
| latestReceipt | Latest receipt for auto-renewable subscriptions | May be present when subscription is renewed |
Note: Server-side verification is more secure than client-side and helps prevent fraud.
Warning: Never hardcode your shared secret in the app. Use a secure backend service.
Server-Side Receipt Verification Steps
- Collect receipt data from the device
- Send it to your backend server securely
- Verify with Apple's servers using shared secret
- Validate receipt contents (bundle ID, purchase dates)
- Grant or deny access based on valid receipts
9. Implementing Promotional Offers
| Property | Description | Notes |
|---|---|---|
| promotionalOffer | SKProductDiscount? |
Represents a promotional offer for a product |
| offerIdentifier | String? |
Unique identifier for the promotional offer |
| price | NSDecimalNumber? |
Price of the promotional offer |
| paymentMode | SKProductDiscount.PaymentMode |
Payment mode (e.g., freeTrial, payAsYouGo) |
| subscriptionPeriod | SKProductSubscriptionPeriod? |
Duration of the subscription period |
Example: Fetching Promotional Offers
import StoreKit
class ProductManager: ObservableObject {
@Published var products: [SKProduct] = []
func fetchProducts() {
let productIdentifiers = Set(["com.example.product1", "com.example.product2"])
let request = SKProductsRequest(productIdentifiers: productIdentifiers)
request.delegate = self
request.start()
}
}
extension ProductManager: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
self.products = response.products
}
}
Example: Applying Promotional Offer
import StoreKit
func purchaseProduct(_ product: SKProduct) {
guard let discount = product.priceLabeledDiscount else { return }
let payment = SKPayment(product: product)
payment.applicationUsername = "user123"
payment.paymentDiscount = discount
SKPaymentQueue.default().add(payment)
}
Note: Promotional offers must be approved in App Store Connect before they can be used.
Warning: Ensure that the user's account is properly authenticated before applying promotional offers.
| Payment Mode | Description | Use Case |
|---|---|---|
| freeTrial | Free trial period for subscription products | Offering free access to premium content |
| payAsYouGo | Reduced price for a limited time | Discounted pricing for promotional campaigns |
10. Testing In-App Purchases
Note: Testing in-app purchases requires an Apple Developer account and Xcode 15 or later.
| Environment | Description | Usage |
|---|---|---|
| Sandbox | Simulated environment for testing in-app purchases without real transactions. | Use during development and testing phases. |
| Production | Live environment where actual purchases occur. | Only used after app approval and deployment. |
Example: Setting up SKTestSession
import StoreKit
class PurchaseManager: ObservableObject {
@Published var products: [SKProduct] = []
func loadProducts() async throws {
let productIdentifiers = ["com.example.product1", "com.example.product2"]
let products = try await SKProductsRequest(products: productIdentifiers).products
self.products = products
}
func purchase(_ product: SKProduct) async throws {
let result = try await SKPaymentQueue.default().addPayment(product)
}
}
Example: Simulating Purchase Flow
import StoreKit
func simulatePurchase() {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
// Handle transaction in delegate
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
print("Purchase successful")
SKPaymentQueue.default().finishTransaction(transaction)
case .failed:
print("Purchase failed")
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
}
}