◉ANTIGRAVITY LABJP
Articles/App Development
▣ App Development/2026-04-05Advanced

Don't Stop at Shipping StoreKit 2 — Growing Subscription Revenue with Server Notifications, Recovery Offers, and Price Experiments

Subscription revenue is decided after launch. Build Server Notifications v2 handling, cohort churn analysis, Promotional Offers, price experiments, and win-back automation — with production code and hands-on operational lessons.

StoreKit 24subscription5revenue optimizationAntigravity386iOS27churn preventionA/B testingApp Store Server NotificationsFirebase Analytics

✦ Premium Article

The day you ship the paywall is day one of operations

When I added StoreKit 2 subscriptions to the apps I have been running as an indie developer since 2014, the first lesson arrived within weeks: shipping the paywall was not the finish line. Subscriber counts crept upward on the dashboard, yet monthly revenue lagged behind what those numbers implied. The gap, I eventually found, was being quietly carved out by failed renewals and first-week cancellations.

Growing subscription revenue means operating three levers simultaneously:

  • Acquisition: Convert free users to paid through trials and strategic offers
  • Retention: Reduce churn and maximize each subscriber's lifetime value (LTV)
  • Win-back: Re-engage churned users with targeted campaigns

The audience here is intermediate-to-advanced iOS developers who already understand StoreKit 2 basics and want to move past them. With Antigravity as a working companion, we will build App Store Server Notifications v2 handling, cohort churn analysis on Firebase Analytics and BigQuery, Promotional Offers, price experiments, and automated win-back — alongside what actually happened when I ran these systems on my own apps.


Churn signals only reach your server — receiving Server Notifications v2

Why Server Notifications Are Essential

Client-side StoreKit 2 APIs alone cannot reliably capture these critical events:

  • Subscription renewal failures (failed payment → grace period)
  • User-initiated cancellations (detecting cancellation intent)
  • Refund requests
  • User responses to price increase notifications

Without server-side visibility into these events, you're flying blind. App Store Server Notifications v2 solves this by having Apple POST these events directly to your server endpoint in real time.

Setting Up the Endpoint with Antigravity

Feed Antigravity the following prompt to scaffold the entire notification endpoint:

Implement an App Store Server Notifications v2 endpoint.
- Use TypeScript + Cloudflare Workers (or Swift + Vapor)
- Process both JWSTransactionDecodedPayload and JWSRenewalInfoDecodedPayload
- Verify JWS signatures against Apple's public keys
- Implement handlers for: SUBSCRIBED, DID_RENEW, EXPIRED, DID_FAIL_TO_RENEW, REFUND

Here's the resulting Cloudflare Workers implementation:

// src/routes/appstore-notifications.ts
import { Hono } from 'hono';
import { verifyAppleJWS, decodeJWSPayload } from '../lib/apple-jws';
 
interface NotificationPayload {
  notificationType: string;        // "SUBSCRIBED" | "DID_RENEW" | "EXPIRED" | ...
  subtype?: string;                // "INITIAL_BUY" | "RESUBSCRIBE" | ...
  data: {
    bundleId: string;
    signedTransactionInfo: string; // JWS-encoded
    signedRenewalInfo: string;     // JWS-encoded
  };
  version: string;                 // "2.0"
}
 
const app = new Hono();
 
app.post('/api/appstore/notifications', async (c) => {
  const body = await c.req.json<{ signedPayload: string }>();
 
  // 1. Verify JWS signature against Apple's PKI root certificate
  const isValid = await verifyAppleJWS(body.signedPayload, c.env.APPLE_ROOT_CERT);
  if (!isValid) {
    return c.json({ error: 'Invalid signature' }, 400);
  }
 
  // 2. Decode the outer payload and nested transaction/renewal payloads
  const payload = decodeJWSPayload<NotificationPayload>(body.signedPayload);
  const txInfo = decodeJWSPayload(payload.data.signedTransactionInfo);
  const renewalInfo = decodeJWSPayload(payload.data.signedRenewalInfo);
 
  // 3. Route to the appropriate handler based on event type
  await handleNotificationEvent(payload.notificationType, txInfo, renewalInfo, c.env);
 
  return c.json({ status: 'ok' });
});
 
async function handleNotificationEvent(
  type: string,
  txInfo: any,
  renewalInfo: any,
  env: Env
) {
  const originalTransactionId = txInfo.originalTransactionId;
  const expiresDate = new Date(txInfo.expiresDate);
 
  switch (type) {
    case 'SUBSCRIBED':
      // New purchase — persist subscription state to KV
      await env.KV.put(`sub:${originalTransactionId}`, JSON.stringify({
        status: 'active',
        expiresDate: expiresDate.toISOString(),
        productId: txInfo.productId,
      }), { expirationTtl: 60 * 60 * 24 * 400 });
      break;
 
    case 'DID_RENEW':
      // Successful renewal — extend expiry
      await updateSubscriptionExpiry(originalTransactionId, expiresDate, env);
      break;
 
    case 'DID_FAIL_TO_RENEW':
      // Payment failure — mark grace period, trigger push notification
      await markGracePeriod(originalTransactionId, renewalInfo.gracePeriodExpiresDate, env);
      await triggerPushNotification(originalTransactionId, 'payment_failed', env);
      break;
 
    case 'EXPIRED':
      // Subscription lapsed — update state, trigger win-back campaign
      await expireSubscription(originalTransactionId, env);
      await triggerWinBackCampaign(originalTransactionId, env);
      break;
 
    case 'REFUND':
      // Refund granted — immediately revoke access
      await revokeSubscription(originalTransactionId, env);
      break;
  }
}
 
export default app;

In my own operations, what kept the implementation order from drifting was deciding up front how urgently each notification type deserves a reaction. Here is the map I settled on:

notificationTypeMeaningRecommended actionPriority
DID_FAIL_TO_RENEWRenewal payment failed (Billing Retry / Grace Period begins)Keep access alive and prompt the user to update payment detailsHighest — left alone, it quietly decays into EXPIRED
DID_CHANGE_RENEWAL_STATUSAuto-renew toggled off (a declared intent to cancel)Re-present value or an offer before the expiry dateHigh — the only window that opens before the subscription actually lapses
EXPIREDSubscription lapsedRecord as the starting point of the win-back sequenceMedium — from here it becomes a re-engagement design problem
REFUNDRefund grantedRevoke access immediately and log the causeHigh — separate fraud signals from dissatisfaction signals
SUBSCRIBED / DID_RENEWNew purchase or successful renewalPersist state and feed KPI aggregationNormal

The one that tends to get overlooked is DID_CHANGE_RENEWAL_STATUS. Cancellation does not first become visible at EXPIRED — in most cases it announces itself days or weeks earlier as "auto-renew off." That time gap is the entire budget you have for presenting a retention offer.

Choose the 16-day Grace Period — most payment failures are accidents

App Store Connect lets you pick a Billing Grace Period of 3, 16, or 28 days; I run with 16. What operating my own apps taught me is that the majority of renewal failures are not decisions to leave — they are expired cards and insufficient balances. Simply keeping access alive lets a meaningful share of these subscriptions recover without the user touching anything.

The inverse mistake is treating the Grace Period like EXPIRED and cutting off access. Nothing convinces a user who was about to recover that the subscription is already gone faster than a locked screen. Branch on gracePeriodExpiresDate in the renewal info and keep the two states strictly separate.


✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A priority-ranked response map for Server Notifications v2 events, including the one that quietly precedes most cancellations
✦Promotional Offer and win-back implementations grounded in real indie-app subscription operations, not just API references
✦A weekly KPI reporting pipeline driven by the scheduled Antigravity CLI so the improvement loop survives real life
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

▣ App Dev2026-05-25
One Month Splitting Antigravity's Inline Edit and Agent Mode Across Four Wallpaper Apps
A month of notes from running Antigravity's Inline Edit and Agent Mode across four production wallpaper apps — with real counts, the decision rule I wrote into AGENTS.md, a one-pass dSYM fix, and how credit cost factors in.
▣ App Dev2026-04-09
Antigravity × App Clip, WidgetKit & Live Activities: Designing an iOS Monetization Funnel
Learn how to design an iOS monetization funnel using App Clip, WidgetKit, and Live Activities with Antigravity. From cold user acquisition to paid conversion, covering StoreKit 2 and RevenueCat integration.
▣ App Dev2026-03-18
Building Production iCloud Sync Apps with SwiftUI and CloudKit in Antigravity
Build a production iCloud sync iOS app with SwiftUI and CloudKit in Antigravity. Covers CKRecord design, offline support, and the queryable-index and Development-to-Production schema pitfalls that break release builds.