Entrevista StoreKit 2: Gestión de Suscripciones y Validación de Recibos
Domina las preguntas de entrevista iOS sobre StoreKit 2, gestión de suscripciones, validación de recibos y las nuevas funcionalidades iOS 18.4 con ejemplos prácticos en Swift.

StoreKit 2 representa un cambio fundamental en la forma en que se gestionan las compras integradas en iOS. Introducido con iOS 15, este framework moderno simplifica drásticamente el código requerido para la gestión de suscripciones y la validación de transacciones. Con iOS 18, StoreKit 1 está oficialmente deprecado, haciendo de StoreKit 2 el único camino soportado. Las entrevistas técnicas iOS abordan regularmente este tema para evaluar la experiencia de los desarrolladores en monetización de aplicaciones.
StoreKit 2 aprovecha una API nativa de Swift con async/await, eliminando la necesidad de callbacks complejos y validación de recibos del lado del cliente. Los entrevistadores valoran a los candidatos que pueden articular las diferencias fundamentales con StoreKit 1, y las nuevas funcionalidades de iOS 18.4 como appTransactionID y SubscriptionOfferView.
Visión General de la Arquitectura StoreKit 2
StoreKit 2 está construido sobre una arquitectura moderna que aprovecha al máximo Swift Concurrency. A diferencia de StoreKit 1, todas las operaciones son asíncronas y utilizan tipos nativos de Swift.
Obtención de Productos Disponibles
El primer paso consiste en cargar los productos configurados en App Store Connect. StoreKit 2 simplifica esta operación con una API declarativa.
import StoreKit
actor ProductService {
// Product identifiers configured in App Store Connect
private let productIdentifiers: Set<String> = [
"com.app.subscription.monthly",
"com.app.subscription.yearly",
"com.app.premium.lifetime"
]
// Product cache to avoid repeated network calls
private var cachedProducts: [Product] = []
func loadProducts() async throws -> [Product] {
// Return cache if available
guard cachedProducts.isEmpty else {
return cachedProducts
}
// Asynchronous request to the 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 }
}
}El uso de un actor garantiza la seguridad de hilos al gestionar el caché de productos.
Tipos de Productos en StoreKit 2
StoreKit 2 distingue claramente entre los diferentes tipos de productos. Comprender esta distinción es esencial para las entrevistas.
import StoreKit
extension Product.ProductType {
var displayName: String {
switch self {
case .consumable:
// Consumable products (credits, lives, etc.)
return "Consumable"
case .nonConsumable:
// Permanent purchases (feature unlock)
return "Non-Consumable"
case .autoRenewable:
// Subscriptions with automatic renewal
return "Auto-Renewable Subscription"
case .nonRenewable:
// Subscriptions without renewal (temporary pass)
return "Non-Renewable Subscription"
@unknown default:
return "Unknown"
}
}
}Las suscripciones con renovación automática representan el modelo de negocio dominante para las aplicaciones iOS modernas.
Manejo de Transacciones de Compra
El proceso de compra en StoreKit 2 se vuelve lineal gracias a async/await. Se acabaron los delegados y callbacks anidados de StoreKit 1.
Iniciar una Compra
A partir de iOS 18.2, las compras requieren un parámetro de contexto de UI. Este cambio asegura que la hoja de compra aparezca correctamente en la jerarquía de vistas de la aplicación.
import StoreKit
import UIKit
actor PurchaseManager {
enum PurchaseError: Error {
case productNotFound
case purchaseCancelled
case purchasePending
case verificationFailed
case unknown
}
func purchase(_ product: Product, in scene: UIWindowScene) async throws -> Transaction {
// Initiate purchase with UI context (iOS 18.2+)
let result = try await product.purchase(confirmIn: scene)
switch result {
case .success(let verification):
// Verify the transaction signature
let transaction = try checkVerification(verification)
// Mark the transaction as finished
await transaction.finish()
return transaction
case .userCancelled:
// User cancelled the purchase modal
throw PurchaseError.purchaseCancelled
case .pending:
// Purchase pending (parental approval, 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 signed and verified by the App Store
return safe
case .unverified(_, let error):
// Invalid or corrupted signature
throw PurchaseError.verificationFailed
}
}
}El método finish() es crucial: indica a la App Store que el contenido ha sido entregado al usuario.
Nunca olvides llamar a transaction.finish() después de entregar el contenido. Una transacción no finalizada se restaurará en el próximo lanzamiento de la app, causando comportamientos inesperados.
Escuchar Transacciones en Segundo Plano
StoreKit 2 introduce Transaction.updates, un flujo asíncrono que emite las transacciones que ocurren fuera de la aplicación (renovaciones, compras de family sharing, etc.).
import StoreKit
actor TransactionObserver {
private var updateTask: Task<Void, Never>?
func startObserving() {
// Cancel any previous observation
updateTask?.cancel()
updateTask = Task(priority: .background) {
// Infinite stream of transaction updates
for await result in Transaction.updates {
do {
let transaction = try self.checkVerification(result)
// Process the transaction based on its type
await self.handleTransaction(transaction)
// Always finish the transaction
await transaction.finish()
} catch {
// Log the error for debugging
print("Transaction verification failed: (error)")
}
}
}
}
func stopObserving() {
updateTask?.cancel()
updateTask = nil
}
private func handleTransaction(_ transaction: Transaction) async {
switch transaction.productType {
case .autoRenewable:
// Update subscription status
await updateSubscriptionStatus(transaction)
case .nonConsumable:
// Unlock premium feature
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 {
// Implementation of status update
}
private func unlockFeature(_ productID: String) async {
// Implementation of feature unlock
}
enum PurchaseError: Error {
case verificationFailed
}
}La observación de transacciones debe iniciarse tan pronto como se lance la aplicación para evitar perder cualquier actualización.
Preguntas Comunes de Entrevista sobre Suscripciones en StoreKit 2
Las entrevistas iOS frecuentemente incluyen preguntas específicas sobre la gestión de suscripciones. Aquí están las más comunes con respuestas detalladas. Para más temas de entrevista Swift, consulta la guía de Swift structured concurrency.
Pregunta 1: ¿Cómo Verificar el Estado Actual de una Suscripción?
StoreKit 2 proporciona acceso directo a los entitlements activos. Nota que Transaction.currentEntitlement(for:) está deprecado desde iOS 18.4. El nuevo currentEntitlements(for:) retorna una secuencia asíncrona para manejar múltiples entitlements (por ejemplo, suscripción más acceso Family Sharing).
import StoreKit
struct SubscriptionStatusChecker {
struct SubscriptionStatus {
let isActive: Bool
let expirationDate: Date?
let willRenew: Bool
let productID: String?
let appTransactionID: UInt64? // iOS 18.4+
}
static func checkCurrentStatus(for productID: String) async -> SubscriptionStatus {
// Use the new currentEntitlements(for:) API (iOS 18.4+)
var latestTransaction: Transaction?
for await result in Transaction.currentEntitlements(for: productID) {
if case .verified(let transaction) = result {
if transaction.productType == .autoRenewable {
if latestTransaction == nil ||
transaction.purchaseDate > latestTransaction!.purchaseDate {
latestTransaction = transaction
}
}
}
}
guard let transaction = latestTransaction else {
return SubscriptionStatus(
isActive: false,
expirationDate: nil,
willRenew: false,
productID: nil,
appTransactionID: nil
)
}
// Check if the subscription is still active
let isActive = transaction.expirationDate ?? Date.distantPast > Date()
// Retrieve renewal information
let renewalInfo = await getRenewalInfo(for: transaction.productID)
return SubscriptionStatus(
isActive: isActive,
expirationDate: transaction.expirationDate,
willRenew: renewalInfo?.willAutoRenew ?? false,
productID: transaction.productID,
appTransactionID: transaction.appTransactionID
)
}
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
}
// Retrieve renewal status
let status = try? await subscription.status.first
return status?.renewalInfo
}
}Este enfoque proporciona el estado preciso de la suscripción sin llamadas al servidor. El campo appTransactionID, introducido en iOS 18.4, permite el seguimiento de clientes a través de transacciones sin llamadas servidor a servidor.
¿Listo para aprobar tus entrevistas de iOS?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Pregunta 2: ¿Cómo Manejar Pruebas Gratuitas y Ofertas Promocionales?
StoreKit 2 simplifica el acceso a la información de las ofertas. Los entrevistadores a menudo prueban la comprensión de los diferentes tipos de ofertas. Desde iOS 18.4, las ofertas promocionales pueden firmarse usando JWS con el nuevo PromotionOfferV2SignatureCreator disponible en la App Store Server Library.
import StoreKit
struct OfferManager {
enum OfferType {
case freeTrial(duration: String)
case payAsYouGo(periods: Int, price: Decimal)
case payUpFront(duration: String, price: Decimal)
case oneTime(price: Decimal) // iOS 17.2+: offer codes for all product types
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:
// Free trial period
return .freeTrial(duration: formatPeriod(offer.period))
case .payAsYouGo:
// Reduced price over multiple periods
return .payAsYouGo(
periods: offer.periodCount,
price: offer.price
)
case .payUpFront:
// Single payment for a period
return .payUpFront(
duration: formatPeriod(offer.period),
price: offer.price
)
case .oneTime:
// One-time offer code (consumables, non-consumables, non-renewing)
return .oneTime(price: offer.price)
@unknown default:
return .none
}
}
static func checkEligibility(for product: Product) async -> Bool {
// Check if user can benefit from the offer
guard let subscription = product.subscription else {
return false
}
// isEligibleForIntroOffer indicates if this is a new subscriber
return await subscription.isEligibleForIntroOffer
}
private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String {
let value = period.value
switch period.unit {
case .day:
return "(value) day(value > 1 ? "s" : "")"
case .week:
return "(value) week(value > 1 ? "s" : "")"
case .month:
return "(value) month(value > 1 ? "s" : "")"
case .year:
return "(value) year(value > 1 ? "s" : "")"
@unknown default:
return ""
}
}
}El método isEligibleForIntroOffer es esencial para evitar mostrar ofertas no disponibles. Nota que los códigos de oferta, anteriormente limitados a suscripciones auto-renovables, ahora soportan consumibles, no consumibles y suscripciones no renovables desde iOS 16.3.
Pregunta 3: ¿Cómo Restaurar Compras Anteriores?
La restauración de compras es una funcionalidad obligatoria según las App Store Review Guidelines.
import StoreKit
actor RestoreManager {
struct RestoreResult {
let restoredProducts: [String]
let activeSubscriptions: [String]
}
func restorePurchases() async throws -> RestoreResult {
var restoredProducts: [String] = []
var activeSubscriptions: [String] = []
// Synchronize with the App Store
try await AppStore.sync()
// Iterate through all user transactions
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
switch transaction.productType {
case .nonConsumable:
// Restore permanent purchases
restoredProducts.append(transaction.productID)
case .autoRenewable:
// Check if subscription is active
if let expiration = transaction.expirationDate,
expiration > Date() {
activeSubscriptions.append(transaction.productID)
}
default:
// Consumables are not restorable
break
}
}
return RestoreResult(
restoredProducts: restoredProducts,
activeSubscriptions: activeSubscriptions
)
}
}AppStore.sync() fuerza la sincronización con los servidores de Apple, útil después de una reinstalación.
SubscriptionOfferView: Presentación Moderna de Suscripciones
iOS 18.4 introduce SubscriptionOfferView, un componente SwiftUI que muestra las opciones de suscripción con carga automática de metadatos del producto. Este es un tema común de entrevista para desarrolladores que construyen aplicaciones basadas en suscripciones.
import SwiftUI
import StoreKit
struct SubscriptionSettingsView: View {
let subscriptionGroupID = "com.app.subscription.group"
var body: some View {
VStack(spacing: 16) {
// Show upgrade options for current subscribers
SubscriptionOfferView(groupID: subscriptionGroupID)
.visibleRelationship(.upgrade)
.prefersPromotionalIcon(true)
.subscriptionOfferViewDetailAction { path in
path.append("subscriptionDetail")
}
// Show current plan details
SubscriptionOfferView(groupID: subscriptionGroupID)
.visibleRelationship(.current)
}
.padding()
}
}El parámetro visibleRelationship controla qué niveles de suscripción aparecen: .upgrade muestra planes un nivel más alto, .downgrade muestra niveles inferiores, .crossgrade muestra niveles equivalentes con mejor valor, y .all muestra todos los planes del grupo.
Validación de Recibos del Lado del Servidor
La validación del lado del servidor sigue siendo recomendada para aplicaciones con suscripciones críticas. StoreKit 2 usa JWS (JSON Web Signatures) para validación moderna. El endpoint deprecado verifyReceipt ha sido reemplazado por la App Store Server API.
Extracción de Datos de Transacción para el Servidor
import StoreKit
struct TransactionPayload: Codable {
let transactionID: String
let originalTransactionID: String
let productID: String
let purchaseDate: Date
let expirationDate: Date?
let jwsRepresentation: String
let appTransactionID: String? // iOS 18.4+
}
actor ServerValidationService {
private let apiEndpoint = "https://api.example.com/verify"
func validateWithServer(_ transaction: Transaction) async throws -> Bool {
// Create payload for the server
let payload = TransactionPayload(
transactionID: String(transaction.id),
originalTransactionID: String(transaction.originalID),
productID: transaction.productID,
purchaseDate: transaction.purchaseDate,
expirationDate: transaction.expirationDate,
jwsRepresentation: transaction.jwsRepresentation ?? "",
appTransactionID: transaction.appTransactionID.map { String($0) }
)
// Encode to JSON
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let jsonData = try encoder.encode(payload)
// Send to server
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
}
// Decode server response
let validationResponse = try JSONDecoder().decode(
ValidationResponse.self,
from: data
)
return validationResponse.isValid
}
}
struct ValidationResponse: Codable {
let isValid: Bool
let subscriptionStatus: String?
}La jwsRepresentation contiene la firma criptográfica verificable por el servidor. El nuevo campo appTransactionID permite el seguimiento preciso de clientes sin viajes adicionales al servidor.
La App Store Server API de Apple proporciona validación del lado del servidor con tokens JWS, Server Notifications V2 para eventos de suscripción en tiempo real, y la nueva SubscriptionStatus API que acepta Transaction IDs (iOS 18.4+) para consultar el estado directamente.
Configuración de Notificaciones del Servidor
Las Server Notifications V2 permiten la recepción de eventos en tiempo real.
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?
}
}Estas notificaciones permiten mantener el estado de las suscripciones sincronizado con el backend.
Manejo de Errores y Casos Límite
Las entrevistas a menudo prueban la comprensión de los escenarios de error y su manejo apropiado. Para más información sobre patrones de arquitectura iOS, consulta la guía de entrevista senior iOS sobre design patterns.
Errores Comunes en 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 "Connection failed. Please check your internet connection."
case .paymentFailed:
return "Payment failed. Please verify your payment information."
case .notAuthorized:
return "In-app purchases are disabled on this device."
case .productNotAvailable:
return "This product is not currently available."
case .unknown:
return "An unexpected error occurred."
}
}
}
static func handle(_ error: Error) -> UserFacingError {
// Check if it's a StoreKit error
if let storeKitError = error as? StoreKitError {
switch storeKitError {
case .networkError:
return .networkError
case .userCancelled:
// Don't show error, user cancelled
return .unknown
case .notAvailableInStorefront:
return .productNotAvailable
case .notEntitled:
return .notAuthorized
default:
return .unknown
}
}
// Purchase errors
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
}
}Un manejo claro de errores mejora la experiencia del usuario y simplifica la depuración.
Soporte para Modo Sin Conexión
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
}
}
// Save locally
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 [:]
}
// Filter expired entitlements
let now = Date()
return entitlements.filter { _, expiration in
expiration > now
}
}
func hasValidEntitlement(for productID: String) -> Bool {
let cached = getCachedEntitlements()
return cached[productID] != nil
}
}El caché local mantiene el acceso a las funciones premium incluso sin conectividad.
Mejores Prácticas para Producción
Arquitectura Recomendada
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() {
// Start transaction observation
startTransactionObserver()
// Load initial state
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()
}
}Esta arquitectura centralizada simplifica la gestión del estado de las compras en toda la aplicación.
Sources
- What's new in StoreKit and In-App Purchase, WWDC25 - Adiciones de API iOS 18.4 incluyendo
appTransactionIDySubscriptionOfferView - App Store Server API Documentation - Validación del lado del servidor con JWS y Server Notifications V2
- StoreKit Documentation - Referencia completa de API para StoreKit 2
Puntos Clave para Entrevistas de StoreKit 2
StoreKit 2 transforma la gestión de compras integradas en una experiencia de desarrollo moderna y segura. Puntos clave para recordar para una entrevista:
- La API nativa async/await elimina los delegados y callbacks complejos de StoreKit 1, que está deprecado desde iOS 18
- La verificación automática mediante
VerificationResultmaneja la validación criptográfica de transacciones Transaction.updatesproporciona un flujo asíncrono para transacciones en segundo plano incluyendo renovaciones y family sharingTransaction.currentEntitlements(for:)reemplaza el deprecadocurrentEntitlement(for:)para manejar múltiples entitlements- El campo
appTransactionID(iOS 18.4+) permite el seguimiento de clientes sin llamadas servidor a servidor SubscriptionOfferViewproporciona UI integrada para presentación de suscripciones convisibleRelationshippara opciones de upgrade, downgrade y crossgrade- Las ofertas promocionales pueden firmarse usando JWS con
PromotionOfferV2SignatureCreator - Los códigos de oferta ahora soportan consumibles, no consumibles y suscripciones no renovables (iOS 16.3+)
- La validación del servidor usa la App Store Server API con tokens JWS, reemplazando el endpoint deprecado
verifyReceipt - Siempre llamar a
transaction.finish()después de la entrega del contenido para evitar comportamiento de restauración inesperado - Implementar caché local de entitlements para una experiencia de usuario fluida sin conectividad
¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
¿Sabrías detectar el bug en iOS?
Un fragmento real, un bug oculto, un intento al día. Sin cuenta para probar.

Escrito por
Anthony Fillion-MailletFundador de SharpSkill
Desarrollador fullstack desde hace más de 10 años. Dirige SharpSkill y responde por todo lo que se publica aquí.
Actualizado el 20 de octubre de 2026
Etiquetas
Compartir
Artículos relacionados

Swift Testing Framework Entrevista 2026: Macros #expect y #require vs XCTest
Domina el nuevo Swift Testing Framework para entrevistas iOS: macros #expect y #require, migración desde XCTest, patrones avanzados y errores comunes.

Entrevista iOS Push Notifications 2026: APNs, tokens y troubleshooting
Guía completa para preparar entrevistas iOS sobre Push Notifications, APNs, gestión de tokens y troubleshooting. Preguntas frecuentes con respuestas detalladas.

Entrevista iOS Senior 2026: Preguntas de Arquitectura y Patrones de Diseño
Prepárate para entrevistas iOS senior con preguntas clave sobre MVVM, VIPER, Clean Architecture y patrones de diseño. Guía completa con ejemplos de código Swift.