Tracking churn with App Store Server Notifications V2
How to measure iOS subscription churn with App Store Server Notifications V2: setup, what each notification means, billing vs voluntary churn, and gaps.
App Store Connect gives you sales and subscription reports, but if you want churn numbers you can trust and act on (who left, why, and who is about to), you need your own event log. On iOS, that log comes from App Store Server Notifications V2: Apple’s server calls yours every time a subscription changes state.
This guide shows how to set them up reliably, how each notification maps to a churn metric, and how to fill the gaps. It is based on Apple’s documentation: Enabling App Store Server Notifications, Responding to App Store Server Notifications, the notificationType reference and Get Notification History (all accessed November 2026).
Setting up the endpoint
Apple’s requirements are short:
- An HTTPS URL on your server, with TLS 1.2 or later. If you specify a port, it must be 443 or 1024 and above.
- Choose version 2 for the URL in App Store Connect. Apple recommends V2 for all new implementations. You can set a separate sandbox URL, or reuse the same one.
- If your firewall uses an allow list, add
17.0.0.0/8, which covers both sandbox and production. - Return HTTP 200 to 206 when you have stored the notification. Any other code counts as a failure.
- Test with the Request a Test Notification endpoint, which sends a
TESTnotification, then check the result with Get Test Notification Status.
Each notification arrives as a signedPayload in JWS format. Verify the signature before trusting anything in it, then decode the payload.
Delivery, retries and duplicates
Things go wrong: deploys, outages, timeouts. Apple’s documentation describes what happens then.
- Retries. For V2, if your server does not return a success code, Apple retries five times, at 1, 12, 24, 48 and 72 hours after the previous attempt. Retries happen in production only; in sandbox Apple sends each notification once.
- Duplicates. Each payload has a
notificationUUID, which Apple says to use to identify duplicates. Store it with a unique constraint and ignore repeats. - History. Get Notification History returns the notifications Apple attempted to send over the past 180 days in production (30 days in sandbox), 20 records per page, optionally filtered by type, subtype or transaction. Run it after any outage.
- Current truth. History records reflect the state at the time of sending. For the current state of a subscription, Apple points to Get All Subscription Statuses.
A good pattern: store the raw notification first, return 200, then process it from a queue. Never do slow work before answering Apple.
What is in a notification
The decoded payload has a notificationType, an optional subtype, a signedDate and a data object. For subscriptions, data contains:
signedTransactionInfo: the transaction (product, dates, offer used, revocation if any).signedRenewalInfo: what will happen at the next renewal, includingautoRenewStatus,expirationIntent,isInBillingRetryPeriod,gracePeriodExpiresDateandeligibleWinBackOfferIds.status: the subscription status as ofsignedDate:1active,2expired,3in billing retry,4in Billing Grace Period,5revoked.
To connect a subscription to one of your users, set an appAccountToken at purchase. Apple returns it in the transaction and renewal info. Without it, you only have Apple’s transaction IDs.
Mapping notifications to churn metrics
Here is how Apple’s notification types translate into the numbers most teams track. Descriptions follow Apple’s notificationType table.
| Metric | Notification | Subtype |
|---|---|---|
| New subscriber | SUBSCRIBED |
INITIAL_BUY |
| Returning subscriber (including win-back offers) | SUBSCRIBED |
RESUBSCRIBE |
| Renewal | DID_RENEW |
none |
| Cancellation intent (renewal turned off) | DID_CHANGE_RENEWAL_STATUS |
AUTO_RENEW_DISABLED |
| Cancellation reversed | DID_CHANGE_RENEWAL_STATUS |
AUTO_RENEW_ENABLED |
| Billing failure | DID_FAIL_TO_RENEW |
GRACE_PERIOD or none |
| Billing recovered | DID_RENEW |
BILLING_RECOVERY |
| Voluntary churn | EXPIRED |
VOLUNTARY |
| Involuntary (billing) churn | EXPIRED |
BILLING_RETRY |
| Price-increase churn | EXPIRED |
PRICE_INCREASE |
| Product removed from sale | EXPIRED |
PRODUCT_NOT_FOR_SALE |
| Refund | REFUND |
none, or SEATS for multiseat |
| Downgrade scheduled | DID_CHANGE_RENEWAL_PREF |
DOWNGRADE |
The expirationIntent in the renewal info gives the same split as a number: 1 customer canceled, 2 billing error, 3 no consent to a price increase or offer conversion, 4 product not available, 5 other (reference).
Count churn at expiry, not at cancellation
On the App Store, a cancellation only stops renewal. The customer keeps access until the period ends, and can turn renewal back on. So:
- Use
AUTO_RENEW_DISABLEDas a leading indicator: subscriptions at risk this period. - Use
EXPIREDas the churn event: subscriptions that actually ended.
If you count churn on AUTO_RENEW_DISABLED, you will overstate it and miss every customer who changed their mind.
Billing churn has a long tail
Apple’s reference says that after a failed renewal, the App Store keeps retrying billing for 60 days, or until the customer fixes their billing or cancels. With Billing Grace Period enabled, the customer keeps access during the grace period; GRACE_PERIOD_EXPIRED marks its end while retries continue. A subscription in billing retry is neither active nor churned yet. Report it as its own bucket, and only count it as churn when EXPIRED with BILLING_RETRY arrives.
Note also recentSubscriptionStartDate in the renewal info: Apple computes it ignoring lapses of 60 days or fewer, which is handy for tenure and cohorts.
A simple churn report from notifications
With every notification stored, a monthly report is a handful of queries:
- Active at start of month: subscriptions whose latest status before the month was active, in grace period or in billing retry (decide once whether retry counts as active and stick to it).
- Churned:
EXPIREDnotifications in the month, split by subtype. - Voluntary churn rate:
EXPIRED/VOLUNTARYdivided by active at start. - Involuntary churn rate:
EXPIRED/BILLING_RETRYdivided by active at start. - Billing recovery:
DID_RENEW/BILLING_RECOVERYdivided byDID_FAIL_TO_RENEWover the same cohort. - At risk now: subscriptions whose latest renewal info has auto-renew off and a future expiry.
- Reactivations:
SUBSCRIBED/RESUBSCRIBE.
For the general formulas and the traps (mixing monthly and annual plans, counting trials), see how to calculate churn rate.
Common gaps
- Only listening to V1, or only production. Test V2 in sandbox before switching production; Apple recommends exactly that order.
- No reconciliation job. Even with retries, run Get Notification History periodically and after incidents.
- Ignoring Family Sharing and groups.
SUBSCRIBEDandREVOKEalso fire for family members and group seats. Decide whether those count as subscribers in your metrics. - Treating refunds as cancellations. A
REFUNDends access and returns money; report it separately. - No
appAccountToken. You get clean App Store numbers but cannot join them with usage, plan or acquisition data, which is where churn explanations come from.
Doing this with NoChurn
NoChurn gives you a URL to paste as your App Store Server Notifications endpoint. It verifies and deduplicates each notification, keeps the timeline per customer (matched by your appAccountToken), separates voluntary, billing and price-increase churn, and flags auto-renew turned off as an early risk signal in the churn score from 0 to 100. You can add your own attributes by API to see what drives churn. Apple keeps billing, retries and the cancel button. See NoChurn + App Store.