StoreKit 2 en entretien : gestion des abonnements et receipts validation
Préparez vos entretiens iOS avec ce guide complet sur StoreKit 2, la gestion des abonnements, la validation des receipts et les questions techniques fréquentes.

StoreKit 2 représente une évolution majeure pour la gestion des achats in-app sur iOS. Introduit avec iOS 15, ce framework moderne simplifie considérablement le code nécessaire pour gérer les abonnements et la validation des transactions. Les entretiens iOS techniques abordent régulièrement ce sujet pour évaluer la maîtrise des développeurs sur la monétisation des applications.
StoreKit 2 utilise une API Swift native avec async/await, éliminant le besoin de callbacks complexes et de la validation manuelle des receipts côté client. Les recruteurs apprécient les candidats capables d'expliquer les différences fondamentales avec StoreKit 1.
Architecture de StoreKit 2
StoreKit 2 repose sur une architecture moderne qui tire parti des fonctionnalités de Swift Concurrency. Contrairement à StoreKit 1, toutes les opérations sont asynchrones et utilisent les types natifs Swift.
Récupération des produits disponibles
La première étape consiste à charger les produits configurés dans App Store Connect. StoreKit 2 simplifie cette opération avec une API déclarative.
import StoreKit
actor ProductService {
// Identifiants des produits configurés dans App Store Connect
private let productIdentifiers: Set<String> = [
"com.app.subscription.monthly",
"com.app.subscription.yearly",
"com.app.premium.lifetime"
]
// Cache des produits pour éviter des appels réseau répétés
private var cachedProducts: [Product] = []
func loadProducts() async throws -> [Product] {
// Retourner le cache si disponible
guard cachedProducts.isEmpty else {
return cachedProducts
}
// Requête asynchrone vers l'App Store
let products = try await Product.products(for: productIdentifiers)
cachedProducts = products
return products
}
func product(for identifier: String) async throws -> Product? {
let products = try await loadProducts()
return products.first { $0.id == identifier }
}
}L'utilisation d'un actor garantit la thread-safety lors de la gestion du cache des produits.
Types de produits StoreKit 2
StoreKit 2 distingue clairement les différents types de produits. Cette distinction est importante à connaître pour les entretiens.
import StoreKit
extension Product.ProductType {
var displayName: String {
switch self {
case .consumable:
// Produits consommables (crédits, vies, etc.)
return "Consommable"
case .nonConsumable:
// Achats permanents (déblocage de fonctionnalité)
return "Non-consommable"
case .autoRenewable:
// Abonnements avec renouvellement automatique
return "Abonnement auto-renouvelable"
case .nonRenewable:
// Abonnements sans renouvellement (pass temporaire)
return "Abonnement non-renouvelable"
@unknown default:
return "Inconnu"
}
}
}Les abonnements auto-renouvelables constituent le modèle économique dominant pour les applications iOS modernes.
Gestion des transactions d'achat
Le processus d'achat avec StoreKit 2 devient linéaire grâce à async/await. Fini les delegates et les callbacks imbriqués de StoreKit 1.
Initiation d'un achat
import StoreKit
actor PurchaseManager {
enum PurchaseError: Error {
case productNotFound
case purchaseCancelled
case purchasePending
case verificationFailed
case unknown
}
func purchase(_ product: Product) async throws -> Transaction {
// Initiation de l'achat avec la modale système
let result = try await product.purchase()
switch result {
case .success(let verification):
// Vérification de la signature de la transaction
let transaction = try checkVerification(verification)
// Marquer la transaction comme terminée
await transaction.finish()
return transaction
case .userCancelled:
// L'utilisateur a annulé la modale d'achat
throw PurchaseError.purchaseCancelled
case .pending:
// Achat en attente (approbation parentale, etc.)
throw PurchaseError.purchasePending
@unknown default:
throw PurchaseError.unknown
}
}
private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let safe):
// Transaction signée et vérifiée par l'App Store
return safe
case .unverified(_, let error):
// Signature invalide ou corrompue
throw PurchaseError.verificationFailed
}
}
}La méthode finish() est cruciale : elle indique à l'App Store que le contenu a été livré à l'utilisateur.
Ne jamais oublier d'appeler transaction.finish() après avoir délivré le contenu. Une transaction non terminée sera restaurée au prochain lancement de l'application, causant des comportements inattendus.
Écoute des transactions en arrière-plan
StoreKit 2 introduit Transaction.updates, un stream asynchrone qui émet les transactions survenues en dehors de l'application (renouvellements, achats familiaux, etc.).
import StoreKit
actor TransactionObserver {
private var updateTask: Task<Void, Never>?
func startObserving() {
// Annuler toute observation précédente
updateTask?.cancel()
updateTask = Task(priority: .background) {
// Stream infini des mises à jour de transactions
for await result in Transaction.updates {
do {
let transaction = try self.checkVerification(result)
// Traiter la transaction selon son type
await self.handleTransaction(transaction)
// Toujours terminer la transaction
await transaction.finish()
} catch {
// Logger l'erreur pour le debugging
print("Transaction verification failed: \(error)")
}
}
}
}
func stopObserving() {
updateTask?.cancel()
updateTask = nil
}
private func handleTransaction(_ transaction: Transaction) async {
switch transaction.productType {
case .autoRenewable:
// Mettre à jour l'état de l'abonnement
await updateSubscriptionStatus(transaction)
case .nonConsumable:
// Débloquer la fonctionnalité premium
await unlockFeature(transaction.productID)
default:
break
}
}
private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let safe):
return safe
case .unverified:
throw PurchaseError.verificationFailed
}
}
private func updateSubscriptionStatus(_ transaction: Transaction) async {
// Implémentation de la mise à jour du statut
}
private func unlockFeature(_ productID: String) async {
// Implémentation du déblocage
}
enum PurchaseError: Error {
case verificationFailed
}
}L'observation des transactions doit démarrer dès le lancement de l'application pour ne manquer aucune mise à jour.
Questions fréquentes sur les abonnements StoreKit 2
Les entretiens iOS comportent souvent des questions spécifiques sur la gestion des abonnements. Voici les plus courantes avec leurs réponses détaillées.
Question 1 : Comment vérifier le statut actuel d'un abonnement ?
StoreKit 2 fournit un accès direct aux entitlements actifs via Transaction.currentEntitlements.
import StoreKit
struct SubscriptionStatusChecker {
struct SubscriptionStatus {
let isActive: Bool
let expirationDate: Date?
let willRenew: Bool
let productID: String?
}
static func checkCurrentStatus() async -> SubscriptionStatus {
// Récupérer toutes les transactions actives
var latestTransaction: Transaction?
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
// Filtrer uniquement les abonnements
if transaction.productType == .autoRenewable {
// Garder la transaction la plus récente
if latestTransaction == nil ||
transaction.purchaseDate > latestTransaction!.purchaseDate {
latestTransaction = transaction
}
}
}
}
guard let transaction = latestTransaction else {
return SubscriptionStatus(
isActive: false,
expirationDate: nil,
willRenew: false,
productID: nil
)
}
// Vérifier si l'abonnement est toujours actif
let isActive = transaction.expirationDate ?? Date.distantPast > Date()
// Récupérer les informations de renouvellement
let renewalInfo = await getRenewalInfo(for: transaction.productID)
return SubscriptionStatus(
isActive: isActive,
expirationDate: transaction.expirationDate,
willRenew: renewalInfo?.willAutoRenew ?? false,
productID: transaction.productID
)
}
private static func getRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? {
guard let product = try? await Product.products(for: [productID]).first,
let subscription = product.subscription else {
return nil
}
// Récupérer le statut de renouvellement
let status = try? await subscription.status.first
return status?.renewalInfo
}
}Cette approche permet d'obtenir un état précis de l'abonnement sans appel serveur.
Prêt à réussir tes entretiens iOS ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Question 2 : Comment gérer les périodes d'essai et les offres promotionnelles ?
StoreKit 2 simplifie l'accès aux informations sur les offres. Les recruteurs testent souvent la compréhension des différents types d'offres.
import StoreKit
struct OfferManager {
enum OfferType {
case freeTrial(duration: String)
case payAsYouGo(periods: Int, price: Decimal)
case payUpFront(duration: String, price: Decimal)
case none
}
static func getIntroductoryOffer(for product: Product) -> OfferType {
guard let subscription = product.subscription,
let offer = subscription.introductoryOffer else {
return .none
}
switch offer.paymentMode {
case .freeTrial:
// Période d'essai gratuite
return .freeTrial(duration: formatPeriod(offer.period))
case .payAsYouGo:
// Prix réduit sur plusieurs périodes
return .payAsYouGo(
periods: offer.periodCount,
price: offer.price
)
case .payUpFront:
// Paiement unique pour une période
return .payUpFront(
duration: formatPeriod(offer.period),
price: offer.price
)
@unknown default:
return .none
}
}
static func checkEligibility(for product: Product) async -> Bool {
// Vérifier si l'utilisateur peut bénéficier de l'offre
guard let subscription = product.subscription else {
return false
}
// isEligibleForIntroOffer indique si c'est un nouvel abonné
return await subscription.isEligibleForIntroOffer
}
private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String {
let value = period.value
switch period.unit {
case .day:
return "\(value) jour\(value > 1 ? "s" : "")"
case .week:
return "\(value) semaine\(value > 1 ? "s" : "")"
case .month:
return "\(value) mois"
case .year:
return "\(value) an\(value > 1 ? "s" : "")"
@unknown default:
return ""
}
}
}La méthode isEligibleForIntroOffer est essentielle pour éviter d'afficher des offres non disponibles.
Question 3 : Comment restaurer les achats précédents ?
La restauration des achats est une fonctionnalité obligatoire selon les guidelines App Store.
import StoreKit
actor RestoreManager {
struct RestoreResult {
let restoredProducts: [String]
let activeSubscriptions: [String]
}
func restorePurchases() async throws -> RestoreResult {
var restoredProducts: [String] = []
var activeSubscriptions: [String] = []
// Synchroniser avec l'App Store
try await AppStore.sync()
// Parcourir toutes les transactions de l'utilisateur
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
switch transaction.productType {
case .nonConsumable:
// Restaurer les achats permanents
restoredProducts.append(transaction.productID)
case .autoRenewable:
// Vérifier si l'abonnement est actif
if let expiration = transaction.expirationDate,
expiration > Date() {
activeSubscriptions.append(transaction.productID)
}
default:
// Les consommables ne sont pas restaurables
break
}
}
return RestoreResult(
restoredProducts: restoredProducts,
activeSubscriptions: activeSubscriptions
)
}
}AppStore.sync() force une synchronisation avec les serveurs Apple, utile après une réinstallation.
Validation des receipts côté serveur
La validation serveur reste recommandée pour les applications avec des abonnements critiques. StoreKit 2 introduit les JWS (JSON Web Signatures) pour une validation moderne.
Extraction des données de transaction pour le serveur
import StoreKit
struct TransactionPayload: Codable {
let transactionID: String
let originalTransactionID: String
let productID: String
let purchaseDate: Date
let expirationDate: Date?
let jwsRepresentation: String
}
actor ServerValidationService {
private let apiEndpoint = "https://api.example.com/verify"
func validateWithServer(_ transaction: Transaction) async throws -> Bool {
// Créer le payload pour le serveur
let payload = TransactionPayload(
transactionID: String(transaction.id),
originalTransactionID: String(transaction.originalID),
productID: transaction.productID,
purchaseDate: transaction.purchaseDate,
expirationDate: transaction.expirationDate,
jwsRepresentation: transaction.jwsRepresentation ?? ""
)
// Encoder en JSON
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(payload)
// Envoyer au serveur
var request = URLRequest(url: URL(string: apiEndpoint)!)
request.httpMethod = "POST"
request.httpBody = jsonData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return false
}
// Décoder la réponse serveur
let validationResponse = try JSONDecoder().decode(
ValidationResponse.self,
from: data
)
return validationResponse.isValid
}
}
struct ValidationResponse: Codable {
let isValid: Bool
let subscriptionStatus: String?
}Le jwsRepresentation contient la signature cryptographique vérifiable par le serveur.
Apple propose désormais l'App Store Server API pour la validation côté serveur. Cette API moderne remplace le deprecated verifyReceipt endpoint et offre des fonctionnalités avancées comme les notifications serveur-à-serveur.
Configuration des notifications serveur
Les Server Notifications V2 permettent de recevoir les événements en temps réel.
enum NotificationType: String, Codable {
case subscribed = "SUBSCRIBED"
case didRenew = "DID_RENEW"
case didFailToRenew = "DID_FAIL_TO_RENEW"
case didChangeRenewalStatus = "DID_CHANGE_RENEWAL_STATUS"
case expired = "EXPIRED"
case gracePeriodExpired = "GRACE_PERIOD_EXPIRED"
case offerRedeemed = "OFFER_REDEEMED"
case priceIncrease = "PRICE_INCREASE"
case refund = "REFUND"
case renewalExtended = "RENEWAL_EXTENDED"
case revoke = "REVOKE"
}
struct ServerNotification: Codable {
let notificationType: NotificationType
let subtype: String?
let data: NotificationData
struct NotificationData: Codable {
let bundleId: String
let environment: String
let signedTransactionInfo: String
let signedRenewalInfo: String?
}
}Ces notifications permettent de maintenir l'état des abonnements synchronisé avec le backend.
Gestion des erreurs et cas limites
Les entretiens testent souvent la compréhension des scénarios d'erreur et leur gestion appropriée.
Erreurs courantes StoreKit 2
import StoreKit
struct PurchaseErrorHandler {
enum UserFacingError {
case networkError
case paymentFailed
case notAuthorized
case productNotAvailable
case unknown
var message: String {
switch self {
case .networkError:
return "Connexion impossible. Vérifiez votre connexion internet."
case .paymentFailed:
return "Le paiement a échoué. Vérifiez vos informations de paiement."
case .notAuthorized:
return "Les achats in-app sont désactivés sur cet appareil."
case .productNotAvailable:
return "Ce produit n'est pas disponible actuellement."
case .unknown:
return "Une erreur inattendue s'est produite."
}
}
}
static func handle(_ error: Error) -> UserFacingError {
// Vérifier si c'est une erreur StoreKit
if let storeKitError = error as? StoreKitError {
switch storeKitError {
case .networkError:
return .networkError
case .userCancelled:
// Ne pas afficher d'erreur, l'utilisateur a annulé
return .unknown
case .notAvailableInStorefront:
return .productNotAvailable
case .notEntitled:
return .notAuthorized
default:
return .unknown
}
}
// Erreurs de purchase
if let purchaseError = error as? Product.PurchaseError {
switch purchaseError {
case .invalidQuantity:
return .unknown
case .productUnavailable:
return .productNotAvailable
case .purchaseNotAllowed:
return .notAuthorized
default:
return .unknown
}
}
return .unknown
}
}Une gestion des erreurs claire améliore l'expérience utilisateur et facilite le debugging.
Gestion du mode hors ligne
import StoreKit
actor OfflineCapabilityManager {
private let userDefaults = UserDefaults.standard
private let entitlementsKey = "cached_entitlements"
func cacheCurrentEntitlements() async {
var entitlements: [String: Date] = [:]
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
entitlements[transaction.productID] = transaction.expirationDate
}
}
// Sauvegarder localement
if let data = try? JSONEncoder().encode(entitlements) {
userDefaults.set(data, forKey: entitlementsKey)
}
}
func getCachedEntitlements() -> [String: Date] {
guard let data = userDefaults.data(forKey: entitlementsKey),
let entitlements = try? JSONDecoder().decode(
[String: Date].self,
from: data
) else {
return [:]
}
// Filtrer les entitlements expirés
let now = Date()
return entitlements.filter { _, expiration in
expiration > now
}
}
func hasValidEntitlement(for productID: String) -> Bool {
let cached = getCachedEntitlements()
return cached[productID] != nil
}
}Le cache local permet de maintenir l'accès aux fonctionnalités premium même sans connexion.
Bonnes pratiques pour la production
Architecture recommandée
import StoreKit
import Combine
@MainActor
final class SubscriptionService: ObservableObject {
static let shared = SubscriptionService()
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
@Published private(set) var subscriptionStatus: SubscriptionStatus = .none
private var transactionObserver: Task<Void, Never>?
enum SubscriptionStatus {
case none
case active(expirationDate: Date)
case expired
case inGracePeriod
}
private init() {
// Démarrer l'observation des transactions
startTransactionObserver()
// Charger l'état initial
Task {
await loadProducts()
await updateSubscriptionStatus()
}
}
private func startTransactionObserver() {
transactionObserver = Task.detached(priority: .background) {
for await result in Transaction.updates {
if case .verified(let transaction) = result {
await transaction.finish()
await self.updateSubscriptionStatus()
}
}
}
}
func loadProducts() async {
do {
let productIDs = [
"com.app.subscription.monthly",
"com.app.subscription.yearly"
]
products = try await Product.products(for: productIDs)
} catch {
print("Failed to load products: \(error)")
}
}
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
if case .verified(let transaction) = verification {
await transaction.finish()
await updateSubscriptionStatus()
}
case .userCancelled:
break
case .pending:
break
@unknown default:
break
}
}
func updateSubscriptionStatus() async {
var purchasedIDs: Set<String> = []
var latestSubscription: Transaction?
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
purchasedIDs.insert(transaction.productID)
if transaction.productType == .autoRenewable {
if latestSubscription == nil ||
transaction.purchaseDate > latestSubscription!.purchaseDate {
latestSubscription = transaction
}
}
}
}
purchasedProductIDs = purchasedIDs
if let subscription = latestSubscription,
let expiration = subscription.expirationDate {
if expiration > Date() {
subscriptionStatus = .active(expirationDate: expiration)
} else {
subscriptionStatus = .expired
}
} else {
subscriptionStatus = .none
}
}
deinit {
transactionObserver?.cancel()
}
}Cette architecture centralisée facilite la gestion de l'état des achats dans toute l'application.
Conclusion
StoreKit 2 transforme la gestion des achats in-app en une expérience de développement moderne et sécurisée. Les points essentiels à retenir pour un entretien :
✅ API async/await native : fin des delegates et callbacks complexes de StoreKit 1
✅ Vérification automatique : VerificationResult gère la validation cryptographique des transactions
✅ Transaction.updates : stream asynchrone pour les transactions en arrière-plan
✅ Transaction.currentEntitlements : accès direct aux droits actifs de l'utilisateur
✅ Offres promotionnelles : isEligibleForIntroOffer pour vérifier l'éligibilité
✅ Validation serveur : JWS et App Store Server API pour une sécurité renforcée
✅ transaction.finish() : obligatoire après livraison du contenu
✅ Gestion hors ligne : cache local des entitlements pour une expérience fluide
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

iOS Push Notifications en entretien en 2026 : APNs, tokens et troubleshooting
Préparez vos entretiens iOS avec ce guide complet sur les Push Notifications, APNs, la gestion des tokens et le troubleshooting. Questions fréquentes et réponses détaillées.

Top 25 questions d'entretien Swift pour développeurs iOS
Préparez vos entretiens iOS avec les 25 questions Swift les plus posées : optionals, closures, ARC, protocols, async/await et patterns avancés.

Combine vs async/await en Swift : patterns de migration progressive
Guide complet sur la migration de Combine vers async/await en Swift : stratégies progressives, bridging patterns, et coexistence des deux paradigmes dans une codebase iOS.