
Una de las causas más silenciosas de pérdida de ingresos en una app de suscripción no es que el cliente cancele, sino que le falle el cobro: una tarjeta caducada, sin saldo o bloqueada por el banco. Es lo que se llama churn involuntario, y el cliente normalmente ni se entera de que ha dejado de pagar.
La buena noticia: App Store tiene un sistema automático para recuperar esos pagos. En esta entrada lo vemos de forma visual y resumida, y centrándonos en lo que tu app puede leer desde el propio dispositivo con StoreKit: cómo detectar el fallo, qué es el periodo de gracia y, sobre todo, cuándo debes cortar el acceso y cuándo no.
Enfoque de esta entrada
Toda esta información (estado de la suscripción, si está en reintento, cuándo acaba la gracia, etc.) se puede consultar de dos formas: a nivel de app (StoreKit, en el dispositivo) y a nivel de servidor (App Store Server API y notificaciones server-to-server). Aquí nos quedamos a nivel de app, que es lo que necesitas si no tienes infraestructura de servidor. Más adelante hay una tabla con la equivalencia entre ambos mundos.
1. El problema: churn involuntario
Churn voluntario
- Desactiva la renovación a propósito.
- En el dispositivo,
willAutoRenewpasa afalse. - Se combate con ofertas de retención y win-back.
Churn involuntario
- Quiere seguir, pero el pago no pasa (tarjeta caducada, sin fondos).
- El estado de la suscripción cambia a reintento o periodo de gracia.
- Se combate con los reintentos y el periodo de gracia configurado en App Store Connect.
El churn involuntario es, en buena medida, recuperable de forma automática: el sistema de reintentos de App Store recupera más del 77% de las renovaciones fallidas a lo largo de 60 días, y más del 80% de esas recuperaciones ocurren en los primeros 16 días.
2. Las dos defensas de App Store
Cuando un cobro de renovación falla, App Store no da por terminada la suscripción de inmediato: pone en marcha hasta dos mecanismos para recuperar el cobro.
Billing Retry (reintento)
Automático y siempre activo. App Store reintenta el cobro durante un máximo de 60 días.
- No requiere configuración.
- Mientras dura, el estado es
.inBillingRetryPeriod. - Si recupera, vuelve a
.subscribed; si se agota, pasa a.expired.
Billing Grace Period (periodo de gracia)
Opcional (se activa en App Store Connect). Mantiene el acceso al servicio mientras se reintenta el cobro.
- Mientras dura, el estado es
.inGracePeriod. - El cliente sigue usando la app durante el reintento.
- Reduce la fricción y mejora la recuperación.
Cuánto dura el periodo de gracia
Se activa una sola vez en App Store Connect (Suscripciones, sección Billing Grace Period, botón Set Up Billing Grace Period) y se aplica a todas las suscripciones auto-renovables de la app: no se puede configurar por producto. Eliges una duración de 3, 16 o 28 días, y la que se aplica de verdad depende de la duración de la suscripción:
| Duración elegida | Suscripción semanal | Mensual y anual |
|---|---|---|
| 3 días | 3 días | 3 días |
| 16 días | 6 días | 16 días |
| 28 días | 6 días | 28 días |
Resumido: 3 o 6 días para suscripciones semanales, y 3, 16 o 28 días para mensuales o más largas. Las semanales se topan en 6 días para que la gracia nunca sea más larga que la propia suscripción.
El periodo gracia se asigna en el momento del error de cobro y no se puede alterar una vez asignada a un usuario. Además: tu elección solo se aplica en producción; en sandbox la duración la marca la cadencia de renovación acelerada de la cuenta de prueba.
3. Una idea clave: App Store no corta tu servicio
Un malentendido habitual: App Store no desactiva las funciones de tu app cuando falla un pago. Lo que hace es gestionar el estado de la suscripción (reintentar el cobro, marcarla en gracia, en reintento o expirada) y exponértelo. Conceder o revocar el acceso es decisión de tu app, leyendo ese estado.
Lo que hace App Store
- Reintenta el cobro.
- Marca el estado (gracia / reintento / expirada).
- Mantiene o retira el entitlement según ese estado.
Lo que hace tu app
- Lee el estado con StoreKit.
- Concede o corta el acceso a tu contenido.
- Avisa al cliente si conviene.
4. Qué ocurre, paso a paso
Recorrido típico de un fallo de pago con periodo de gracia activado, visto desde el estado de la suscripción en el dispositivo (RenewalState):
Tarjeta caducada, sin saldo, rechazo del banco...
.inGracePeriod.Mantienes el acceso hasta
gracePeriodExpirationDate. El cliente no nota nada..subscribed.Se conserva el ciclo de facturación. Fin feliz, sin interrupción.
.inBillingRetryPeriod.Ahora sí cortas el acceso; el reintento continúa sin servicio.
.expired.Con
expirationReason == .billingError. Aquí encaja una campaña de win-back.Sin periodo de gracia la línea es más corta: del fallo se pasa directamente a .inBillingRetryPeriod (cortando el acceso), y de ahí a .subscribed (recuperación) o a .expired (fin).
5. Leerlo desde la app
El punto de partida es Product.SubscriptionInfo.Status, que obtienes con product.subscription?.status. Cada estado trae tres cosas: el state (un RenewalState), la transaction y la renewalInfo.
RenewalState: los cinco estados
expirationReason para saber por qué.Campos de RenewalInfo a vigilar
| Campo (en el dispositivo) | Para qué |
|---|---|
isInBillingRetry | Indica si la suscripción está en reintento de cobro. Útil para detectar el riesgo y decidir si avisar al cliente. |
gracePeriodExpirationDate | Fecha hasta la que se debe mantener el acceso durante la gracia. Si no es nil, estás en periodo de gracia. |
willAutoRenew | Si la renovación sigue activada. Ayuda a distinguir churn voluntario (la desactivó) de involuntario (falló el pago). |
expirationReason | En una suscripción ya expirada, el motivo: .billingError, .autoRenewDisabled, .didNotConsentToPriceIncrease, .productUnavailable... |
En código
guard let statuses = try await product.subscription?.status else { return }
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
switch status.state {
case .subscribed:
// al día: concede acceso
case .inGracePeriod:
// falló el cobro pero mantienes acceso hasta gracePeriodExpirationDate
case .inBillingRetryPeriod:
// reintentando sin gracia: normalmente corta el acceso
case .expired:
// terminada; renewalInfo.expirationReason == .billingError, etc.
case .revoked:
// reembolso o Family Sharing
}
}
Para el control de acceso en sí, recorre Transaction.currentEntitlements: durante .inGracePeriod la transacción sigue ahí, así que el acceso continúa sin que tengas que hacer nada especial.
6. Lo mismo, a nivel de servidor
Por si en el futuro montas infraestructura de servidor, esta es la equivalencia. No necesitas nada de esto para lo anterior: con la app basta. Solo cambian los nombres y el canal.
| Qué quieres saber | En el dispositivo (StoreKit) | En el servidor |
|---|---|---|
| Estado de la suscripción | RenewalState (.inGracePeriod...) | campo status (4 = gracia, 3 = reintento) |
| Está reintentando el cobro | isInBillingRetry | isInBillingRetryPeriod |
| Hasta cuándo dura la gracia | gracePeriodExpirationDate | gracePeriodExpiresDate |
| Motivo de expiración | expirationReason | expirationIntent |
| El cobro falló o se recuperó | cambio en RenewalState | notificaciones DID_FAIL_TO_RENEW / DID_RECOVER |
7. Avisar al cliente sin sacarlo de la app
StoreKit incluye una API de mensajes del sistema, Message, que se gestiona desde el cliente. Cuando hay un problema de facturación, App Store prepara un mensaje con motivo billingIssue; tú decides cuándo presentar esa hoja del sistema, que invita al cliente a corregir su método de pago sin abandonar tu app.
import StoreKit
// Escucha los mensajes del App Store (configuralo al arrancar la app)
for await message in Message.messages {
// Solo nos interesa el problema de facturacion; el resto se pueden diferir
guard message.reason == .billingIssue else { continue }
// UIKit: indica que la app esta lista para mostrarlo
guard let windowScene = view.window?.windowScene else { continue }
try? message.display(in: windowScene)
// SwiftUI: presentalo con @Environment(\.displayStoreKitMessage) (DisplayMessageAction)
}
Escuchar no basta: si no llamas a display(in:) (UIKit) o a DisplayMessageAction (SwiftUI) tras recibir el mensaje, App Store lo suprime. Ese es justo el mecanismo para diferirlo hasta un momento oportuno. Otros motivos de Message.Reason son .priceIncreaseConsent (subida de precio), .generic y .winBackOffer. Y presentarlo no garantiza que se muestre: si el cliente ya resolvió el problema, no aparecerá nada.
8. Probarlo en sandbox
Cómo simular un fallo de pago
En el dispositivo de prueba, dentro de los ajustes de la cuenta de Sandbox, desactiva "Allow Purchases & Renewals" para forzar un fallo de renovación y disparar el reintento. Verás cómo el estado de la suscripción cambia a .inGracePeriod o .inBillingRetryPeriod sin tocar nada de servidor. Recuerda que la duración del periodo de gracia en sandbox la marca la cadencia de renovación acelerada de la cuenta, no las duraciones de producción.
9. Errores que conviene evitar
No hagas
- Cortar el acceso cuando el estado es
.inGracePeriod. - Asumir que App Store deshabilita tus funciones por ti.
- Decidir el acceso sin mirar el
RenewalState. - Confundir los nombres del cliente (
isInBillingRetry) con los del servidor (isInBillingRetryPeriod).
Sí haz
- Activar el periodo de gracia salvo que tengas una razón para no hacerlo.
- Conceder acceso con
currentEntitlementsy afinar el mensaje con elRenewalState. - Avisar con la hoja
billingIssuecuando detectes el problema. - Gestionar estados desconocidos sin romper (Apple añade nuevos cada año).
- Reservar el win-back para
.expired, no para el periodo de reintento.
10. Conclusión
El fallo de pago no es el final de la relación con el suscriptor: es un bache que App Store intenta resolver por ti. Tu trabajo es no estorbar. Recuerda tres cosas: App Store gestiona el estado, pero el acceso lo concede tu app leyendo RenewalState y currentEntitlements; activa el periodo de gracia para no penalizar al cliente por algo que no decidió; y distingue las rutas, mantén durante .inGracePeriod, recupera al volver a .subscribed y corta solo en reintento sin gracia o en .expired. Pruébalo de principio a fin en el sandbox desactivando "Allow Purchases & Renewals", y deja el win-back para cuando la suscripción haya expirado de verdad.
Bibliografía y fuentes consultadas
- Apple Developer. Reducing involuntary subscriber churn.
- Apple Developer. Product.SubscriptionInfo.RenewalState.
-
Apple Developer.
Product.SubscriptionInfo.RenewalInfo (incluye
isInBillingRetryygracePeriodExpirationDate). -
Apple Developer.
StoreKit Message (motivo
billingIssue). - Apple Developer Help. Enable Billing Grace Period for auto-renewable subscriptions.
- WWDC19, sesión 302. Subscription Offers and the Billing Grace Period.
- A nivel de servidor (no usado en esta entrada, como referencia):
-
Apple Developer.
App Store Server Notifications V2 (
DID_FAIL_TO_RENEW,DID_RECOVER,GRACE_PERIOD_EXPIRED,EXPIRED). - Apple Developer. Get All Subscription Statuses (App Store Server API).