StoreKit 2 sin RevenueCat: guía de producción para indies#
Cuando construí la monetización de MeteoHealth —una app de salud sensible al clima con modelo freemium— la primera pregunta no fue "cómo escribo el código", sino "¿de verdad necesito un intermediario entre mi app y el App Store?". Las funciones básicas de MeteoHealth son gratuitas, y la suscripción Pro desde $2.99/mes desbloquea analítica avanzada y pronósticos extendidos. La opción por defecto de la industria para este problema es integrar RevenueCat en media hora y no pensar más en los detalles. Yo tomé deliberadamente otro camino y llevo seis meses con StoreKit 2 puro en producción. Este artículo no es un repaso teórico de la API, sino un informe de lo que esa decisión realmente costó y aportó: qué resultó más simple de lo esperado, qué requirió atención real, y en qué casos te aconsejaría no seguir mi ejemplo.
Por qué no integré RevenueCat#
El argumento "RevenueCat es gratis hasta $2,500/mes de ingresos" es cierto, y para muchos indies eso zanja la cuestión de inmediato. Pero tres factores en MeteoHealth hicieron que StoreKit 2 puro fuera la opción más racional:
- Una sola plataforma. La app es solo para iOS, sin Android ni versión web. El valor principal de RevenueCat —una capa de abstracción única sobre App Store y Google Play— simplemente no aplica cuando no hay nada que abstraer.
- Una suscripción, un grupo. El modelo de monetización es simple: un nivel gratuito y un único nivel Pro. Sin matriz de tarifas, sin experimentos regionales de precio que justifiquen un dashboard aparte.
- Control sobre la lógica de entitlement. Quería que la verificación de acceso a las funciones Pro viviera en mi propio código, no dentro de la caja negra de un SDK de terceros que puede cambiar de forma en una actualización mayor.
Aquí conviene separar el hecho de la opinión. Hecho: StoreKit 2 cubre realmente el 100% de lo que necesita una app iOS de una sola plataforma con una sola suscripción —compra, restauración, verificación de estado, códigos de oferta—. Opinión: si MeteoHealth tuviera tres niveles de precio, un experimento regional de paywall y planes para Android, elegiría RevenueCat sin dudarlo, y más abajo explico por qué.
Arquitectura: Product, Transaction y currentEntitlements#
El núcleo de la lógica de suscripciones en StoreKit 2 no es la UI ni App Store Connect, sino tres entidades: Product (la descripción del artículo, obtenida de la tienda), Transaction (la prueba de compra, firmada criptográficamente por Apple) y Transaction.currentEntitlements (el conjunto actual de derechos activos del usuario en este momento, sin necesidad de caché manual).
Agrupé todo esto en una única clase @MainActor que vive durante todo el ciclo de vida de la app y escucha los cambios de transacciones en segundo plano:
import StoreKit
@MainActor
final class SubscriptionManager: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
private let productIDs = ["pro.monthly", "pro.yearly"]
private var updatesTask: Task<Void, Never>?
init() {
updatesTask = observeTransactionUpdates()
Task {
await loadProducts()
await refreshEntitlements()
}
}
deinit {
updatesTask?.cancel()
}
func loadProducts() async {
do {
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):
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
case .userCancelled, .pending:
break
@unknown default:
break
}
}
func refreshEntitlements() async {
var active: Set<String> = []
for await result in Transaction.currentEntitlements {
guard let transaction = try? checkVerified(result) else { continue }
if transaction.revocationDate == nil {
active.insert(transaction.productID)
}
}
purchasedProductIDs = active
}
private func observeTransactionUpdates() -> Task<Void, Never> {
Task(priority: .background) { [weak self] in
for await result in Transaction.updates {
guard let transaction = try? self?.checkVerified(result) else { continue }
await transaction.finish()
await self?.refreshEntitlements()
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let safe):
return safe
}
}
}
enum StoreError: Error {
case failedVerification
}Lo único que me quemó una vez: Transaction.currentEntitlements es una secuencia asíncrona, no una foto fija. Si llamas a refreshEntitlements() antes de que StoreKit termine de sincronizar con el App Store tras un arranque en frío, puedes obtener brevemente un conjunto de derechos vacío y mostrarle un paywall a un suscriptor que ya pagó. La solución no es una única llamada al arrancar, sino mantener viva una Task escuchando Transaction.updates durante todo el ciclo de vida de la app, como en el código anterior.
Verificar el estado de la suscripción: periodo de gracia y billing retry#
El error más común en implementaciones caseras es comprobar solo .subscribed y cortar el acceso en cualquier otro estado. Eso rompe el periodo de gracia y el billing retry —estados que Apple diseñó específicamente para no perder usuarios de pago por una tarjeta caducada.
Product.SubscriptionInfo.Status da el detalle necesario:
extension SubscriptionManager {
/// Returns true if the user should keep Pro access,
/// including grace period and billing retry — not just `.subscribed`.
func hasActiveProAccess(for group: Product.SubscriptionInfo?) async -> Bool {
guard let statuses = try? await group?.status else { return false }
for status in statuses {
switch status.state {
case .subscribed, .inGracePeriod:
return true
case .inBillingRetryPeriod:
// Apple is still retrying the charge — don't cut access yet.
return true
case .expired, .revoked:
continue
default:
continue
}
}
return false
}
}Para MeteoHealth esto no fue un caso teórico: los usuarios con tarjetas caducadas o reemitidas no son raros, y la diferencia entre "mostrar un aviso de problema de pago" y "desactivar de inmediato la analítica Pro" impacta directamente en la retención. Como la app no tiene backend propio para suscripciones, toda esta lógica vive en el dispositivo, sin un servidor que tenga que revalidar recibos. Para una sola suscripción y un solo nivel, es un compromiso honesto: menos infraestructura, menos puntos de fallo.
SubscriptionStoreView: un paywall sin capa de UI propia#
Antes de iOS 17, había que construir el paywall a mano: tu propio layout, tus propios estados de carga, tu propio manejador de compra. SubscriptionStoreView elimina buena parte de esa rutina —basta con pasarle el ID del grupo de suscripción, y StoreKit se encarga del resto (precios, localización, restore, estado de carga):
struct PaywallView: View {
let groupID: String
var body: some View {
SubscriptionStoreView(groupID: groupID) {
VStack(spacing: 12) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.largeTitle)
Text("Unlock Pro Forecasts")
.font(.title2.bold())
Text("Advanced analytics and extended forecasts for $2.99/mo")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding()
}
.storeButton(.visible, for: .restorePurchases)
.subscriptionStoreControlStyle(.prominentPicker)
.onInAppPurchaseCompletion { product, result in
if case .success(.success) = result {
// refresh entitlements, dismiss paywall, etc.
}
}
}
}No abandoné SubscriptionStoreView del todo: lo usé como esqueleto base y pasé el contenido de marketing (icono, titular, descripción de las funciones Pro) a través del closure de arriba. La única limitación con la que me topé: la personalización del layout de las tarjetas de precio está limitada al conjunto de subscriptionStoreControlStyle incorporados, y para un paywall realmente a medida (tablas comparativas complejas de funciones, animaciones) igual hay que construir la vista desde cero sobre Product y .task(id:).
Win-back offers: recuperar suscriptores que se fueron#
Desde iOS 18, Apple añadió los win-back offers —ofertas dirigidas a usuarios cuya suscripción ya caducó, no a los que siguen activos. Es un tipo de oferta distinto, se configura en App Store Connect para una suscripción concreta y, a diferencia de las promotional offers, solo está disponible para antiguos suscriptores.
extension SubscriptionManager {
func availableWinBackOffer(for product: Product) async -> Product.SubscriptionOffer? {
guard let status = try? await product.subscription?.status.first,
case .expired = status.state else { return nil }
let eligible = await product.subscription?.eligibleWinBackOffers ?? []
return eligible.first
}
func redeem(_ offer: Product.SubscriptionOffer, for product: Product) async throws {
let result = try await product.purchase(options: [.winBackOffer(offer)])
if case .success(let verification) = result {
let transaction = try checkVerified(verification)
await transaction.finish()
await refreshEntitlements()
}
}
}Para una app freemium como MeteoHealth, esto cierra un escenario muy concreto: un usuario se suscribió a Pro un mes para consultar el pronóstico antes de un viaje, canceló, y seis meses después ve en la app una oferta de regreso con descuento, sin que yo tenga que escribir ni mantener mi propia maquinaria de retención. La condición: la oferta debe estar configurada de antemano en App Store Connect, o eligibleWinBackOffers siempre devuelve un array vacío.
StoreKit Testing en Xcode: pruebas sin dinero real#
Una de las partes más infravaloradas de StoreKit 2 no es la API, sino las herramientas. Un archivo de configuración .storekit en Xcode permite describir productos, grupos de suscripción, ofertas promocionales y win-back offers de forma local, sin pasar por App Store Connect y sin pagos reales.
Lo que realmente uso de este conjunto:
- StoreKit Configuration File — una descripción local de productos; el esquema de StoreKit se sincroniza con este archivo al ejecutar en el simulador o en un dispositivo desde Xcode.
- Transaction Manager en Xcode — permite revocar manualmente una transacción para verificar que la app reacciona correctamente a un reembolso al usuario.
- Simular fallos de pago y billing retries directamente desde los ajustes del scheme —sin esperar a que una tarjeta real del Apple ID caduque para probar el manejo de
.inBillingRetryPeriod. - Probar win-back offers localmente — configura una oferta en el archivo
.storekity ve al instante cómo se muestra a una cuenta de prueba ya caducada, sin esperar un ciclo real de suscripción.
La conclusión práctica: todo el pipeline —desde la primera compra hasta la cancelación, el periodo de gracia y la oferta de win-back— se puede recorrer en un solo día de trabajo en el simulador, sin enviar un solo dólar real al App Store.
StoreKit 2 vs RevenueCat: cuándo sí recomendaría RevenueCat#
Aquí toca ser honesto en lugar de venderte una respuesta "correcta" como si fuera universal. StoreKit 2 puro le sentó bien a MeteoHealth precisamente por su modelo de monetización simple y su arquitectura solo-iOS. Eso no es una recomendación general.
| Criterio | StoreKit 2 (nativo) | RevenueCat |
|---|---|---|
| Costo | Siempre gratis | Gratis hasta $2,500/mes de ingresos, luego un porcentaje |
| Multiplataforma (iOS + Android + Web) | Hay que escribirlo por separado para cada plataforma | API y dashboard únicos para todas |
| Tiempo hasta la primera suscripción en vivo | Un par de días (Product, Transaction, entitlements) | Un par de horas |
| Analítica de MRR, churn, conversión | Hay que construirla tú mismo | Dashboard listo desde el primer día |
| Pruebas A/B de paywalls | Implementación manual de experimentos | Experimentos integrados |
| Sincronización server-side (webhooks) | App Store Server Notifications a mano | Webhooks gestionados |
| Control sobre la lógica y las actualizaciones de API | Total, depende solo de Apple | Depende de las versiones del SDK |
Recomendaría RevenueCat si tienes: (1) un producto en varias plataformas y no quieres duplicar la lógica de suscripciones; (2) varios niveles de precio y planes para experimentar con precios y ofertas más de una vez por trimestre; (3) un equipo sin experiencia profunda en StoreKit para quien la velocidad de salida importa más que el control fino; (4) ingresos por debajo del umbral de $2,500/mes en el que RevenueCat sigue siendo gratis —en ese caso no hay ahorro al construirlo tú mismo, solo estás gastando tiempo sin necesidad. Nada de eso aplicaba a MeteoHealth, así que StoreKit 2 puro se queda en producción —pero revisaré esta decisión el día que aparezca una segunda plataforma.
Mirando atrás, habría adoptado antes App Store Server Notifications v2 —incluso sin backend propio, un simple manejador serverless de notificaciones cierra huecos que en el cliente solo se ven con retraso (una disputa de cargo iniciada por el banco, por ejemplo). Nada de esto cambia la conclusión del artículo: para una app iOS de un solo nivel, StoreKit 2 sin intermediarios es una decisión arquitectónica sólida y defendible, siempre que estés dispuesto a aprender los estados de la suscripción una vez, en lugar de delegar ese conocimiento a un SDK de terceros. El tiempo invertido en aprender Transaction, currentEntitlements y SubscriptionStoreView se paga solo con saber exactamente por qué un usuario concreto ve o no ve las funciones Pro —y ese conocimiento no desaparece con la siguiente actualización mayor de iOS.



