MVP Factory
ai startup development

Subscription Recovery Architecture for iOS and Android: Grace Periods, Billing Retry, and the Server-Side Webhook Pipeline That Recovers 15% of Involuntary Churn

KW
Krystian Wiewiór · · 6 min read

TL;DR: Involuntary churn — failed payments from expired cards, insufficient funds, billing errors — accounts for 20–40% of all subscription cancellations. By building an idempotent server-side webhook pipeline that processes Apple and Google billing retry events, manages grace period state machines, and triggers coordinated re-engagement notifications, you can recover roughly 15% of that lost revenue. This post walks through the architecture.


The problem most teams ignore

In my experience building production subscription systems, teams obsess over voluntary churn (users actively canceling) while letting involuntary churn silently drain revenue. The numbers are hard to argue with: data from RevenueCat and Adapty consistently shows that 20–40% of churn is involuntary. The user wanted to stay subscribed. Their payment just failed.

Both Apple and Google now provide server-side notification systems for exactly this scenario. The hard part is building a pipeline that handles both platforms coherently.

Webhook event taxonomy

Most teams get this wrong by treating Apple and Google webhooks as identical. They aren’t. The event naming, timing, and retry semantics differ in ways that will bite you.

Lifecycle StageApple (App Store Server Notifications V2)Google Play (Real-Time Developer Notifications)
Payment failsDID_FAIL_TO_RENEWSUBSCRIPTION_IN_BILLING_RETRY_PERIOD
Grace period activesubtype: GRACE_PERIODSUBSCRIPTION_IN_GRACE_PERIOD
Account hold beginsN/A (Apple uses billing retry)SUBSCRIPTION_ON_HOLD
Recovery succeedsDID_RENEWSUBSCRIPTION_RECOVERED
Final expirationEXPIRED (subtype: BILLING_RETRY_PERIOD)SUBSCRIPTION_EXPIRED

Apple’s grace period lasts 6 or 16 days depending on billing cycle. Google offers a configurable grace period (default 3–7 days) plus an additional account hold period of up to 30 days. This asymmetry matters a lot for your state machine design.

The state machine

Your entitlement service needs a unified subscription state that abstracts over both platforms:

enum class SubscriptionState {
    ACTIVE,
    GRACE_PERIOD,      // Payment failed, user retains access
    BILLING_RETRY,     // Past grace, platform retrying (Google: account hold)
    EXPIRED,           // All recovery attempts exhausted
    RECOVERED          // Transient state → transitions to ACTIVE
}

The key architectural decision: users retain full access during GRACE_PERIOD and degraded or no access during BILLING_RETRY. This isn’t purely a product decision. Apple requires you to maintain access during their grace period if you opt in.

Idempotent event pipeline

Your webhook ingestion layer must be idempotent. Both Apple and Google retry delivery on failure, and network issues cause duplicates. Plan for it.

@PostMapping("/webhooks/apple")
suspend fun handleAppleNotification(@RequestBody payload: SignedPayload) {
    val notification = appleJWSVerifier.verify(payload)
    val eventId = notification.notificationUUID

    // Idempotency check — deduplicate on event ID
    if (eventStore.exists(eventId)) {
        return ResponseEntity.ok().build()
    }

    eventStore.save(
        ProcessedEvent(
            id = eventId,
            platform = Platform.APPLE,
            type = notification.notificationType,
            originalTransactionId = notification.data.transactionInfo.originalTransactionId,
            processedAt = Instant.now()
        )
    )

    subscriptionStateMachine.transition(notification)
}

A few things that tripped us up in practice:

  1. Always return 2xx immediately after persisting the raw event, then process asynchronously. Apple retries with exponential backoff for up to 72 hours on non-2xx responses. Google retries for up to 3 days. You don’t want duplicate processing because your handler was slow.
  2. Verify signatures. Apple V2 notifications are JWS-signed. Google RTDN messages come through Cloud Pub/Sub with built-in authentication. Never process unverified payloads.
  3. Use the platform’s transaction ID as your correlation key: originalTransactionId for Apple, purchaseToken for Google.

The retry notification strategy

Processing webhooks passively isn’t enough. You need an active notification strategy coordinated with the platform’s own retry schedule.

Grace Period Day 1  → Push: "Your payment failed — update your card to keep access"
Grace Period Day 3  → Email: "You are about to lose access to [Premium Feature]"
Billing Retry Day 1 → Push: "Your subscription is paused — tap to restore"
Billing Retry Day 7 → Email: "We miss you — here is a direct link to update payment"

In production systems I’ve worked on, this four-touch sequence across push and email recovers approximately 12–18% of billing failures that would otherwise churn. The median across multiple apps sits around 15%.

Both platforms support deep linking directly to payment method update screens:

  • iOS: StoreKit.AppStore.showManageSubscriptions(in:) opens the native subscription management sheet
  • Android: Direct the user to https://play.google.com/store/account/subscriptions with your package name and SKU as parameters

Reducing friction from “notification received” to “payment method updated” is the biggest win in this entire pipeline. Everything else is plumbing. This is the part that actually moves the number.

Coordinating entitlement access

Your entitlement check becomes a function of the state machine, not a simple boolean:

fun resolveAccess(subscription: Subscription): AccessLevel = when (subscription.state) {
    ACTIVE, RECOVERED -> AccessLevel.FULL
    GRACE_PERIOD -> AccessLevel.FULL  // Required by Apple if opted in
    BILLING_RETRY -> AccessLevel.DEGRADED  // Show upgrade prompts
    EXPIRED -> AccessLevel.NONE
}

The DEGRADED state during billing retry is worth thinking about carefully. Show the user what they’re missing without fully locking them out. In my experience this converts better than a hard paywall, because the user didn’t choose to leave. They just have a dead card in their wallet.

Monitoring and alerting

Track these in your observability stack:

  • Recovery rate: percentage of DID_FAIL_TO_RENEW / BILLING_RETRY events that eventually resolve to RECOVERED / DID_RENEW
  • Grace period conversion: percentage recovered during grace period vs. during billing retry
  • Webhook processing lag: p95 latency from event receipt to state machine transition
  • Duplicate event rate: validates your idempotency logic is working

What I’d do if I were starting from scratch

Build a unified state machine that abstracts Apple and Google billing states into a single subscription lifecycle. The platform differences in grace period duration and account hold semantics demand a normalization layer. Don’t handle them with platform-specific if/else branches scattered through your codebase. That path leads to bugs you won’t catch until they’ve cost you money.

Implement a time-sequenced notification strategy across push and email during grace period and billing retry windows. Passive webhook processing alone leaves real recovery on the table. The active notification sequence is where the 15% recovery rate comes from.

Invest in idempotent event processing and observability from day one. Webhook delivery is at-least-once, not exactly-once. Without deduplication on event IDs and clear metrics on recovery rates, you’ll have data integrity issues and no visibility into how much revenue your pipeline is actually saving.

TAGS: kotlin, android, ios, mobile, architecture


Share: Twitter LinkedIn