iOS Client Integration Guide
Complete guide for integrating iOS in-app purchases with the Zishes backend.
Quick Start
1. Get Receipt from App Store
After user completes a purchase via StoreKit, retrieve the App Store receipt:
import StoreKit
func getAppStoreReceipt() -> String? {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else {
print("Could not find App Store receipt")
return nil
}
return receiptData.base64EncodedString()
}
2. Call the Backend API
func recordIosPurchase(userId: String, planId: String, receipt: String) {
let url = URL(string: "https://api.zishes.com/api/v1/public/iap/ios")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"uid": userId,
"pid": planId,
"receipt": receipt,
"status": "PAID",
"sandbox": false
]
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
handleResponse(data)
} else if let error = error {
print("Error: \(error.localizedDescription)")
}
}.resume()
}
func handleResponse(_ data: Data) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("Invalid response")
return
}
if let success = json["success"] as? Bool, success,
let dataDict = json["data"] as? [String: Any],
let points = dataDict["points"] as? Int {
print("✅ Purchase successful! \(points) points credited.")
// Update UI with new point balance
} else {
let message = json["message"] as? String ?? "Unknown error"
print("❌ Purchase failed: \(message)")
}
}
Complete Implementation Examples
Example 1: Simple Purchase Handler
import Foundation
import StoreKit
class IAPManager {
static let shared = IAPManager()
private let apiBaseURL = "https://api.zishes.com/api/v1/public/iap/ios"
func recordPurchase(
userId: String,
planId: String,
forSandbox sandbox: Bool = false
) async {
// Get receipt
guard let receipt = getReceipt() else {
print("No receipt available")
return
}
// Prepare request
var request = URLRequest(url: URL(string: apiBaseURL)!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload = IAPPayload(
uid: userId,
pid: planId,
receipt: receipt,
status: "PAID",
sandbox: sandbox
)
request.httpBody = try? JSONEncoder().encode(payload)
// Send request
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
print("Invalid response")
return
}
let result = try JSONDecoder().decode(IAPResponse.self, from: data)
if result.success, let points = result.data?.points {
print("✅ Purchased \(points) points!")
} else {
print("❌ \(result.message)")
}
} catch {
print("Error: \(error.localizedDescription)")
}
}
private func getReceipt() -> String? {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else {
return nil
}
return receiptData.base64EncodedString()
}
}
// MARK: - Models
struct IAPPayload: Codable {
let uid: String // User ID
let pid: String // Plan ID
let receipt: String // Base64-encoded receipt
let status: String // "PAID", "FAILED", "PENDING"
let sandbox: Bool
}
struct IAPResponse: Codable {
let status: String
let message: String
let success: Bool
let data: IAPData?
}
struct IAPData: Codable {
let points: Int
let isDuplicate: Bool
let transactionId: String?
let country: String
let purchaseStatus: String
}
Example 2: SwiftUI Integration
import SwiftUI
import Combine
@main
struct ZishesApp: App {
@StateObject var iapManager = IAPManager.shared
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(iapManager)
}
}
}
struct ContentView: View {
@EnvironmentObject var iapManager: IAPManager
@State private var selectedPlanId = ""
@State private var isLoading = false
@State private var purchaseResult: PurchaseResult?
var body: some View {
VStack {
Text("Select a Plan")
.font(.title)
List {
ForEach(MOCK_PLANS, id: \.id) { plan in
Button(action: { selectedPlanId = plan.id }) {
HStack {
VStack(alignment: .leading) {
Text(plan.name)
.font(.headline)
Text("\(plan.points) points")
.font(.subheadline)
.foregroundColor(.gray)
}
Spacer()
if selectedPlanId == plan.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
}
}
}
}
}
Button(action: { purchasePlan() }) {
if isLoading {
ProgressView()
} else {
Text("Purchase")
}
}
.disabled(selectedPlanId.isEmpty || isLoading)
.buttonStyle(.borderedProminent)
.padding()
if let result = purchaseResult {
VStack {
if result.success {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title)
Text("Success!")
.font(.headline)
Text("\(result.points) points credited")
.foregroundColor(.gray)
} else {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
.font(.title)
Text("Failed")
.font(.headline)
Text(result.message)
.foregroundColor(.gray)
}
}
.padding()
}
}
}
private func purchasePlan() {
isLoading = true
Task {
let userId = UIApplication.shared.userId // Get from user session
await iapManager.recordPurchase(userId: userId, planId: selectedPlanId)
DispatchQueue.main.async {
isLoading = false
// Update purchase result
}
}
}
}
struct PurchaseResult {
let success: Bool
let points: Int
let message: String
}
let MOCK_PLANS = [
(id: "plan_500", name: "Starter", points: 500),
(id: "plan_1000", name: "Plus", points: 1000),
(id: "plan_5000", name: "Premium", points: 5000),
]
Example 3: Combine-based Manager
import Foundation
import Combine
class IAPViewModel: NSObject, ObservableObject {
@Published var purchaseState: PurchaseState = .idle
private let apiURL = URL(string: "https://api.zishes.com/api/v1/public/iap/ios")!
private var cancellables = Set<AnyCancellable>()
func recordPurchase(userId: String, planId: String) {
purchaseState = .loading
guard let receipt = getReceipt() else {
purchaseState = .failed("No receipt available")
return
}
let payload: [String: Any] = [
"uid": userId,
"pid": planId,
"receipt": receipt,
"status": "PAID",
"sandbox": false
]
var request = URLRequest(url: apiURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
URLSession.shared.dataTaskPublisher(for: request)
.tryMap { output in
guard let response = output.response as? HTTPURLResponse,
(200...299).contains(response.statusCode) else {
throw URLError(.badServerResponse)
}
return output.data
}
.decode(type: IAPResponse.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink { completion in
if case .failure(let error) = completion {
self.purchaseState = .failed(error.localizedDescription)
}
} receiveValue: { response in
if response.success, let data = response.data {
self.purchaseState = .success(points: data.points)
} else {
self.purchaseState = .failed(response.message)
}
}
.store(in: &cancellables)
}
private func getReceipt() -> String? {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else {
return nil
}
return receiptData.base64EncodedString()
}
}
enum PurchaseState {
case idle
case loading
case success(points: Int)
case failed(String)
}
Handling Different Scenarios
Duplicate Purchase
If the same receipt is submitted again:
let response = try JSONDecoder().decode(IAPResponse.self, from: data)
if response.success {
if response.data?.isDuplicate == true {
print("⚠️ This purchase was already processed")
print("points: \(response.data?.points ?? 0)")
} else {
print("✅ New purchase recorded")
print("points: \(response.data?.points ?? 0)")
}
}
Sandbox Testing
For testing in sandbox environment:
func recordSandboxPurchase(userId: String, planId: String, receipt: String) {
var payload = [
"uid": userId,
"pid": planId,
"receipt": receipt,
"status": "PAID",
"sandbox": true // ← Set to true for sandbox
] as [String : Any]
// ... rest of the implementation
}
Failed/Pending Purchases
Record purchases with different statuses:
func recordPurchaseStatus(
userId: String,
planId: String,
receipt: String,
status: PurchaseStatus
) {
let payload: [String: Any] = [
"uid": userId,
"pid": planId,
"receipt": receipt,
"status": status.rawValue // "PAID", "FAILED", or "PENDING"
]
// ... send request
}
enum PurchaseStatus: String {
case paid = "PAID"
case failed = "FAILED"
case pending = "PENDING"
}
Error Handling Best Practices
func handleIAPError(_ error: IAPError) {
switch error {
case .noReceipt:
print("User needs to complete purchase in App Store")
case .invalidReceipt:
print("Receipt validation failed. Try again.")
case .userNotFound:
print("User account not found. Please log in again.")
case .planNotFound:
print("Plan is no longer available.")
case .networkError(let message):
print("Network error: \(message). Check connection and try again.")
case .serverError(let message):
print("Server error: \(message). Please try again later.")
case .duplicatePurchase:
print("This purchase was already processed. Your points are credited.")
}
}
enum IAPError: Error {
case noReceipt
case invalidReceipt
case userNotFound
case planNotFound
case networkError(String)
case serverError(String)
case duplicatePurchase
}
Verify Receipt Endpoint (Optional)
To verify a receipt without processing:
func verifyReceipt(_ receipt: String, sandbox: Bool = false) async -> Bool {
let url = URL(string: "https://api.zishes.com/api/v1/public/iap/ios/verify")!
let payload: [String: Any] = [
"receipt": receipt,
"sandbox": sandbox
]
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
return false
}
let result = try JSONDecoder().decode(VerifyResponse.self, from: data)
return result.data?.isValid ?? false
} catch {
print("Verification error: \(error)")
return false
}
}
struct VerifyResponse: Codable {
let status: String
let success: Bool
let data: VerifyData?
}
struct VerifyData: Codable {
let isValid: Bool
let status: Int
let bundleId: String?
let inAppPurchases: [Receipt]?
}
struct Receipt: Codable {
let product_id: String
let transaction_id: String
let original_transaction_id: String
let purchase_date_ms: String
}
Notification Integration
When a purchase is successful, the backend sends a notification:
// Request user permission for notifications
import UserNotifications
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, error in
if granted {
print("Notification permission granted")
}
}
// Handle incoming notification
// In your AppDelegate or SceneDelegate:
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
if let notificationType = userInfo["type"] as? String,
notificationType == "PLAN_PURCHASE_SUCCESS" {
print("✅ Plan purchase notification received!")
print("Message: \(userInfo["message"] ?? "")")
// Update UI, refresh point balance, etc.
}
completionHandler([.banner, .sound, .badge])
}
Testing Checklist
- Test with real App Store receipt in production
- Test with sandbox receipt using
sandbox: true - Test duplicate purchase detection
- Test with invalid user ID
- Test with invalid plan ID
- Test network error handling
- Test notification receipt
- Verify points are credited to correct wallet
- Check ledger entries in database
- Verify transaction ID is stored for idempotency
Performance Tips
- Cache user ID: Store user ID in keychain or UserDefaults
- Batch requests: If multiple purchases, queue them
- Timeout handling: Set reasonable timeouts for network requests
- Retry logic: Implement exponential backoff for failed requests
- Offline handling: Cache receipt and retry when online
// Example: Simple retry with exponential backoff
func recordPurchaseWithRetry(
userId: String,
planId: String,
receipt: String,
maxRetries: Int = 3,
delay: TimeInterval = 1.0
) async {
for attempt in 1...maxRetries {
do {
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
// Try request...
return
} catch {
if attempt == maxRetries {
print("Failed after \(maxRetries) attempts")
return
}
print("Retry attempt \(attempt)...")
}
}
}
Troubleshooting
| Issue | Solution |
|---|---|
| Receipt not found | Ensure purchase completed before calling API |
| Invalid receipt | Check receipt is Base64-encoded correctly |
| User not found | Verify user is logged in and user ID is correct |
| Plan not found | Check plan ID is valid in database |
| Duplicate detected | This is normal - points already credited on first attempt |
| Network timeout | Check internet connection, try again |
| Server error (500) | Contact backend team, check IOS_APP_SHARED_SECRET is configured |
Additional Resources
- Apple StoreKit Documentation
- App Store Connect
- Receipt Validation Guide
- Backend API Docs:
docs/ios-iap-api.md