Build Paystack Subscriptions in iOS with Swift
Subscribe a user by calling your backend to initialize a transaction with a Paystack plan code. Open the authorization URL in WKWebView. After checkout, Paystack creates the subscription and your backend receives the webhook. Check subscription status from iOS by calling your backend API — not Paystack directly.
Important: App Store Guidelines
If you distribute on the App Store and sell digital subscriptions consumed within the app, Apple requires in-app purchases via StoreKit. Paystack subscriptions are appropriate for:
- Enterprise/B2B apps distributed outside the App Store
- Apps with subscriptions for physical goods or services
- TestFlight builds and internal distribution
- Web-based SaaS where iOS is a companion app
Check the current App Store Review Guidelines before launching.
Subscription Model in Swift
// Models/SubscriptionStatus.swift
struct SubscriptionStatus: Codable {
let active: Bool
let planName: String?
let nextBillingDate: String?
let subscriptionCode: String?
let emailToken: String?
enum CodingKeys: String, CodingKey {
case active
case planName = "plan_name"
case nextBillingDate = "next_billing_date"
case subscriptionCode = "subscription_code"
case emailToken = "email_token"
}
}
// SubscriptionService.swift
extension PaymentService {
func getSubscriptionStatus(email: String) async throws -> SubscriptionStatus {
let encodedEmail = email.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? email
guard let url = URL(string: "(baseURL)/api/subscription/status?email=(encodedEmail)") else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(SubscriptionStatus.self, from: data)
}
func cancelSubscription(code: String, token: String) async throws {
guard let url = URL(string: "(baseURL)/api/subscription/cancel") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"subscription_code": code,
"email_token": token
])
let (_, _) = try await URLSession.shared.data(for: request)
}
}
SwiftUI Subscription View
// SubscriptionView.swift
import SwiftUI
@MainActor
class SubscriptionViewModel: ObservableObject {
@Published var status: SubscriptionStatus?
@Published var loading = false
@Published var error: String?
func load(email: String) {
loading = true
Task {
do {
status = try await PaymentService.shared.getSubscriptionStatus(email: email)
} catch {
self.error = error.localizedDescription
}
loading = false
}
}
func cancel() {
guard let code = status?.subscriptionCode,
let token = status?.emailToken else { return }
loading = true
Task {
do {
try await PaymentService.shared.cancelSubscription(code: code, token: token)
status = SubscriptionStatus(active: false, planName: nil,
nextBillingDate: nil,
subscriptionCode: nil, emailToken: nil)
} catch {
self.error = "Cancellation failed: (error.localizedDescription)"
}
loading = false
}
}
}
struct SubscriptionView: View {
let userEmail: String
@StateObject private var viewModel = SubscriptionViewModel()
var body: some View {
Group {
if viewModel.loading {
ProgressView("Loading...")
} else if let status = viewModel.status {
if status.active {
VStack(alignment: .leading, spacing: 16) {
Label("Active Subscription", systemImage: "checkmark.seal.fill")
.font(.headline)
.foregroundColor(.green)
if let planName = status.planName {
Text("Plan: (planName)")
}
if let nextDate = status.nextBillingDate {
Text("Next billing: (nextDate)")
.foregroundColor(.secondary)
}
Button("Cancel Subscription", role: .destructive) {
viewModel.cancel()
}
.padding(.top)
}
.padding()
} else {
VStack(spacing: 16) {
Text("No active subscription")
NavigationLink("Choose a Plan") {
PlanSelectionView(userEmail: userEmail)
}
.buttonStyle(.borderedProminent)
}
}
}
}
.onAppear { viewModel.load(email: userEmail) }
.alert("Error", isPresented: Binding(
get: { viewModel.error != nil },
set: { if !$0 { viewModel.error = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(viewModel.error ?? "")
}
}
}
Learn More
This guide is part of the Paystack integration guides by language and framework series.
Key Takeaways
- ✓Subscription checkout in iOS uses the same WKWebView flow as one-time payment with a plan code on the backend.
- ✓After checkout, Paystack manages renewals automatically. iOS does not trigger billing.
- ✓Check subscription status via your backend. Display plan name, next billing date, and status in SwiftUI.
- ✓Use @EnvironmentObject or @StateObject for subscription state that needs to be shared across views.
- ✓Remember App Store guidelines: selling digital content through an iOS app requires using StoreKit/IAP unless exempt.
- ✓Paystack subscriptions are ideal for B2B apps, enterprise apps, and services delivered outside the App Store.
Frequently Asked Questions
- How does the iOS app know when a subscription is active?
- The backend receives the subscription.create webhook and marks the user as subscribed. The iOS app calls a backend status endpoint on app launch and after checkout. Use @StateObject with async/await to load and observe the subscription state in your SwiftUI views.
- Can I use Paystack subscriptions if my iOS app is on the App Store?
- Only if the subscription is for a physical service or an external platform. Apple requires StoreKit/IAP for digital content subscriptions within App Store apps. For B2B apps, enterprise distribution, or companion apps for web services, Paystack may be appropriate.
- How do I implement a paywall in SwiftUI with Paystack?
- Create a SubscriptionViewModel that loads status from your backend. In your premium feature views, check viewModel.status?.active. If false, show a PlanSelectionView that starts the subscription checkout flow. Use @EnvironmentObject to share the subscription status across the app without passing it manually.
- How do I cancel a Paystack subscription from iOS?
- Call your backend cancel endpoint, which calls POST /subscription/disable on Paystack. You need the subscription_code and email_token from the subscription.create webhook. Store these on your backend and expose them in the subscription status API.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon