Skip to main content

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

  1. Cache user ID: Store user ID in keychain or UserDefaults
  2. Batch requests: If multiple purchases, queue them
  3. Timeout handling: Set reasonable timeouts for network requests
  4. Retry logic: Implement exponential backoff for failed requests
  5. 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

IssueSolution
Receipt not foundEnsure purchase completed before calling API
Invalid receiptCheck receipt is Base64-encoded correctly
User not foundVerify user is logged in and user ID is correct
Plan not foundCheck plan ID is valid in database
Duplicate detectedThis is normal - points already credited on first attempt
Network timeoutCheck internet connection, try again
Server error (500)Contact backend team, check IOS_APP_SHARED_SECRET is configured

Additional Resources