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 optimizationAntigravity338iOS27churn 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-07-04
Paid, but the Ads Won't Go Away — Closing a StoreKit 2 Transaction.updates Launch Race with Antigravity
How I traced a StoreKit 2 launch race that dropped transactions arriving right after startup, pinned the listener to the app's lifetime instead of a view's, reconciled with currentEntitlements, and let Antigravity turn it into a StoreKitTest regression suite.
App Dev2026-06-23
When StoreKit 2 Users Say They Paid but Can't Access — Field Notes on Subscription Entitlement Drift
StoreKit 2 subscriptions are harder to operate than to implement. This is a record of the drift between currentEntitlements, subscription.status, and server notifications — and the reconcile logic that finally stopped the support tickets.
App Dev2026-07-03
When Universal Links Break Silently — Catching Association Drift with an Agent-Run Verification Gate
Universal Links and App Links fall back to the browser with no error when your association files or entitlements drift apart. Here is a design that generates the files from one source of truth and hands weekly and pre-release checks to an agent.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →