Home / Blog / StoreKit 2 promotional offers: the setup that cut churn by 12 points

StoreKit 2 promotional offers: the setup that cut churn by 12 points

Two years of subscription data showed a pattern I couldn’t ignore: churn spiked hard on day 90. I had no pause or downgrade option in place before users hit cancel. Rolling out promotional offers moved that number sharply. Here are the numbers and the code. Defining the problem My Mixpanel dashboard showed day-90 churn like […]

Two years of subscription data showed a pattern I couldn’t ignore: churn spiked hard on day 90. I had no pause or downgrade option in place before users hit cancel. Rolling out promotional offers moved that number sharply. Here are the numbers and the code.

Defining the problem

My Mixpanel dashboard showed day-90 churn like this:

  • 28% of active users cancelled on day 90
  • Cancellation survey: 41% “too expensive”, 22% “features I don’t use”, 18% “I’m not using it enough”

Three distinct categories, three distinct promotional offer strategies.

My three offer types

1. Retention offer: 50% off for three months. For the “too expensive” cohort.

2. Pause offer: two months free. For the “I’m not using it enough” cohort.

3. Upgrade offer: move to the annual plan with the first two months free. The monthly equivalent on the annual plan is 30% cheaper.

I defined the offer IDs in App Store Connect ahead of time. Each offer has its own identifier.

Eligibility check

import StoreKit

func fetchOffers(for product: Product) async -> [Product.SubscriptionOffer] {
    guard let subscription = product.subscription else { return [] }
    var eligible: [Product.SubscriptionOffer] = []
    for offer in subscription.promotionalOffers {
        let status = await subscription.isEligibleForIntroOffer
        if offer.type == .promotional {
            eligible.append(offer)
        }
    }
    return eligible
}

Don’t confuse a promotional offer with an intro offer. An intro offer is first purchase only. A promotional offer is for an existing or lapsed subscriber.

The signature has to come from your server

Applying a promotional offer needs a signed payload from Apple. That payload is signed on your server with a private key. The private key is the .p8 file you download from App Store Connect.

// Backend (Node.js example)
const jwt = require('jsonwebtoken');
const fs = require('fs');
function signOffer(productId, offerId, username, nonce) {
    const privateKey = fs.readFileSync('./SubscriptionKey.p8');
    const payload = {
        appBundleID: 'com.mycompany.app',
        keyIdentifier: 'ABCDEF123',
        productIdentifier: productId,
        subscriptionOfferID: offerId,
        applicationUsername: username,
        nonce: nonce,
        timestamp: Date.now()
    };
    return jwt.sign(payload, privateKey, {
        algorithm: 'ES256',
        header: { alg: 'ES256', kid: 'ABCDEF123' }
    });
}

When the client presents the offer, it hits the backend, gets the signed payload, and passes it into the purchase call.

Purchase with offer

let signatureData = try await api.fetchOfferSignature(
    productId: product.id,
    offerId: offer.id,
    userId: currentUser.id
)
let result = try await product.purchase(options: [
    .promotionalOffer(
        offerID: offer.id,
        keyID: signatureData.keyID,
        nonce: signatureData.nonce,
        signature: signatureData.signatureData,
        timestamp: signatureData.timestamp
    )
])
switch result {
case .success(let verification):
    // transaction finish
case .userCancelled:
    break
case .pending:
    break
@unknown default: break
}

Triggering: when to show it

Showing the offer on every screen is tempting but wrong. You need strategic moments. Mine are:

  • User lands on the “cancel” screen in settings
  • User hasn’t opened the app in 7 days and comes back from a push notification
  • User is approaching day 90 (subscription expires in 3 days)
  • User opens the “change plan” screen

Those triggers are coded as a state machine on the frontend. I show at most one offer per user per month. That keeps it from feeling spammy.

Results

Three months later:

  • Day-90 churn dropped from 28% to 16% (12 points absolute, 43% relative)
  • Retention offer acceptance: 38%
  • Pause offer acceptance: 22% (of whom 58% come back two months later)
  • Upgrade offer acceptance: 15% (but the most valuable on LTV)

Average MRR per user fell by 8% because of the discounts, but total MRR grew by 11% because retention outran the discounts.

Pitfalls

1. Signature timestamp validity: the signed payload is only valid for a few minutes. If the user sees the offer, waits ten minutes, and then taps purchase, the signature has expired. Re-sign on every purchase attempt.

2. Keep eligibility in sync with the backend: if you only show an offer to certain users (say 60+ day subscribers), don’t trust the client for that check. Do it on the server.

3. In App Store Connect, make sure the offer is marked “Available in all territories”. Otherwise eligibility returns false in some countries and the purchase silently fails.

Closing advice

Promotional offers take about a week to ship. The payoff is measurable in three months. If you have a subscription app, especially one where 30+ day retention is weak, make this your first optimisation.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch