The Problem You're Actually Solving

You already have a working RevenueCat integration. New subscribers are flowing through your paywall, trials are converting, and webhooks are firing. Now your growth team wants three things you haven't implemented yet: win back churned subscribers, present retention discounts to at-risk subscribers, and run a campaign with redeemable codes.

Each of these sounds like a minor addition, but each one has its own credential requirements, SDK version constraints, failure modes, and tracking quirks. Get any one wrong and you end up with a postOfferIdSignature error in production, a presentCodeRedemptionSheet call that silently does nothing, or win-back redemptions that never appear in RevenueCat because Apple processed them server-side.

This tutorial cuts through those surprises.


The Common Foundation: In-App Purchase Keys

Before writing a single line of offer code, you need an In-App Purchase Key uploaded to RevenueCat. This is not optional—it's a hard dependency for Promotional Offers, Offer Codes, and Win-Back Offers alike.

Critical: When using Purchases SDK v5.x+ (StoreKit 2), transactions will fail to be recorded entirely without this key. Users who complete a purchase can end up without the entitlements they paid for. Source

The SDK versions that enforce this requirement include iOS 5.0.0+, React Native 8.0.0+, Flutter 7.0.0+, Capacitor 9.0.0+, Cordova 6.0.0+, and Unity 7.0.0+. Source

Generating and Uploading the Key

  1. In App Store Connect, navigate to Users and Access → Integrations → In-App Purchase.
  2. Click Generate In-App Purchase Key. You get one chance to download the .p8 file — save it securely.
  3. In the RevenueCat dashboard, open your App Store app under Apps & providers, go to the In-app purchase key configuration tab, and upload the .p8.
  4. Copy the Issuer ID from the same App Store Connect page and paste it into the RevenueCat field.

⚠️ If the Issuer ID field is empty in App Store Connect, create any App Store Connect API key — that action causes the Issuer ID to appear. The same ID applies to both the IAP key and the App Store Connect key. Source

After uploading, use the credential validation feature in the dashboard to confirm all permissions are green. An "Invalid permissions" status almost always means the bundle ID has a typo, the Issuer ID is wrong, or the key has been revoked.


Offer Type Reference

All four Apple subscription offer types are supported by the Purchases SDK, but with very different requirements:

Type Target Users IAP Key Auto-Applied Min SDK
Introductory Offers New users Required (SK2) ✅ Yes
Promotional Offers Existing & lapsed ✅ Required ❌ No iOS 12.2+
Offer Codes New & existing ✅ Required ❌ No SDK 3.8.0+
Win-Back Offers Lapsed users only ✅ Required Via StoreKit SDK 5.x

Source

⚠️ Legacy In-App Purchase Promo Codes (not the same as Offer Codes) are explicitly not recommended. They are treated as regular purchases, break revenue accuracy in Charts, can't be used with presentCodeRedemptionSheet, don't auto-renew, and Apple stopped allowing new code creation after March 26, 2026. Source


Part 1: Promotional Offers

What They Are

Promotional Offers let you present a discounted price to a subscriber who is active, lapsed, or at risk of churning. Unlike introductory offers, they're fully developer-controlled — you decide who sees them and when. There are three discount structures: pay-up-front, pay-as-you-go, and free (trial-style).

1. Configure in App Store Connect

Inside App Store Connect, navigate to your subscription product → Subscription Prices+Create Promotional Offer. Set the Reference Name (your label) and the Promotional Offer Product Code (the identifier your app will use). Choose your offer type and duration, then hit Save.

Allowed durations vary by type: pay-up-front accepts 1–12 months; pay-as-you-go accepts 1–12 months; free trials accept 3 days through 1 year. Source

2. Fetch and Apply the Offer in Code

The RevenueCat SDK uses a two-step process: first get a signed discount object from the backend, then purchase with it. The signing call is what contacts RevenueCat's servers and uses the IAP key — this is exactly where postOfferIdSignature errors surface.

import RevenueCat

// Step 1: Get the discount you want to apply
// storeProduct is the StoreProduct for the subscription
// discount is a StoreProductDiscount from storeProduct.discounts

guard let discount = storeProduct.discounts.first(where: { 
    $0.offerIdentifier == "your_promo_offer_code" 
}) else {
    // Offer not found on this product — check App Store Connect config
    return
}

// Step 2: Ask RevenueCat to sign the offer
do {
    let promotionalOffer = try await Purchases.shared.getPromotionalOffer(
        forProductDiscount: discount,
        product: storeProduct
    )

    // Step 3: Purchase with the signed offer
    let result = try await Purchases.shared.purchase(
        product: storeProduct,
        promotionalOffer: promotionalOffer
    )

    // Handle result.customerInfo for entitlement updates
    print("Purchase successful: \(result.customerInfo)")
} catch {
    // Check error.localizedDescription or rc_code_name
    print("Promotional offer purchase failed: \(error)")
}

Source

3. Displaying via Customer Center

If you're using RevenueCat's Customer Center, you can configure it to automatically present promotional offers as retention discounts during cancellation and refund flows — no manual getPromotionalOffer call required. The Cancellation Retention Discount and Refund Retention Discount are configurable in the Customer Center's Offers tab. Source

Debugging: postOfferIdSignature

This is the most common promotional offer error. It falls under UNEXPECTED_BACKEND_RESPONSE_ERROR and means RevenueCat received a signature error from Apple when attempting to sign the offer. Root causes, in order of likelihood:

  1. Missing or invalid IAP Key — the .p8 file is either not uploaded, revoked, or the Issuer ID doesn't match.
  2. Wrong offer identifier — the string passed as offerIdentifier doesn't exactly match what's in App Store Connect.
  3. Offer not saved in App Store Connect — easy to forget the Save button after creating the offer.
  4. Propagation delay — new offers can take up to 24 hours to become available.

Other UNEXPECTED_BACKEND_RESPONSE_ERROR sub-codes to know: postOfferIdBadResponse (malformed response from signing endpoint) and postOfferIdMissingOffersInResponse (offer exists but wasn't returned). Source


Part 2: Win-Back Offers

What They Are

Win-Back Offers, introduced with iOS 18, let you target subscribers who have previously canceled. Apple determines eligibility based on developer-configured criteria; if the user qualifies, Apple can surface the offer directly in the App Store — before the user even opens your app. Source

Requirements checklist: - iOS 18.0+ on the user's device - RevenueCat iOS SDK 5.x (StoreKit 2 required) - In-App Purchase Key uploaded to RevenueCat - Apple App Store Server Notifications configured

Why Server Notifications Are Non-Optional Here

Unlike promotional offers, win-back offers can be redeemed directly through the App Store — the user may never open your app. Without Apple Server Notifications configured in RevenueCat, those redemptions are invisible to your backend. Source

Set RevenueCat as the server notification endpoint in App Store Connect (App Information → App Store Server Notifications). If you need to forward notifications to your own server too, use RevenueCat's forwarding URL feature rather than manually piping raw Apple payloads. Source

Redemption Flow: Three Integration Paths

The RevenueCat SDK 5.x supports three win-back redemption scenarios. Source

Path A: Automatic via StoreKit Message (Default Behavior)

When the app opens and the SDK is configured to show StoreKit messages automatically, the RevenueCat SDK calls PurchasesDelegate with the pending win-back message and presents the native StoreKit win-back offer sheet automatically. No additional code needed in your app.

Path B: Manual via StoreKit Message (Deferred Display)

If you need to control when the sheet appears (e.g., don't interrupt an onboarding flow), you can defer the display:

// In your AppDelegate or SceneDelegate, configure the SDK
// to not auto-show StoreKit messages
Purchases.configure(
    with: Configuration.Builder(withAPIKey: "your_api_key")
        .build()
)

// Then in your PurchasesDelegate, handle the message manually:
extension YourDelegate: PurchasesDelegate {
    func purchases(
        _ purchases: Purchases,
        readyForPromotedProduct product: StoreProduct,
        purchase startPurchase: @escaping StartPurchaseBlock
    ) {
        // Store startPurchase and call it when appropriate
        self.pendingPurchase = startPurchase
    }
}

// Call when you're ready to show the win-back sheet:
pendingPurchase?()

Source

Path C: In-App StoreKit StoreView

For SwiftUI apps, you can embed a StoreView that surfaces win-back offers inline. This requires iOS SDK 5.x and uses StoreKit 2's native UI. Source

Configuring Win-Back Offers in App Store Connect

Navigate to your subscription → Win-Back Offers+. Set the Reference Name and the Offer Identifier (the string you'll reference in analytics). Then configure Customer Eligibility criteria — this is the developer-controlled filter that determines which churned subscribers Apple will target. Source


Part 3: Offer Codes

What They Are

Offer Codes let you distribute unique alphanumeric codes that users redeem for discounted or free subscription periods. They work for both new and existing subscribers. Unlike promotional offers, they're distributed out-of-band (email, social, QR code, etc.).

Presenting the Redemption Sheet

// Present the native iOS offer code redemption sheet
// Requires iOS SDK 3.8.0+
Purchases.shared.presentCodeRedemptionSheet()

Source

Alternatively, RevenueCat's Paywall builder supports an offer code redemption button component that calls this sheet when tapped — useful if you want users to access code redemption from your paywall UI without writing the call manually. Source

The Revenue Tracking Caveat

This is a known limitation worth flagging to your team before launch:

Due to limitations in available information, accurate revenue tracking is not yet supported on initial purchases made with Offer Codes, and those purchases will be set to $0 in Charts. Source

Renewal revenue after the initial code redemption is tracked normally. Plan your CAC reporting accordingly.

Stability Notes on presentCodeRedemptionSheet

The presentCodeRedemptionSheet API has historically had StoreKit-level stability issues. The most important known issue: iOS 18.2.x introduced a bug where payment sheets — including the redemption sheet — fail to appear if the presenting view controller uses .fullScreen modal style. Source

Workaround for iOS 18.2.x: - UIKit: Switch from modalPresentationStyle = .fullScreen to .overFullScreen - SwiftUI: Switch from fullScreenCover(isPresented:) to sheet(isPresented:)

This was resolved in iOS 18.3, but if your analytics show a drop in code redemptions for that window, this is the culprit.

Additionally, there was a separate iOS 18.3.1–18.5 bug where StoreKit incorrectly returned a "canceled" state after a successful purchase (triggered by the Receipt Renewal email prompt). This affected all purchase flows including code redemption. It has since been resolved by Apple. Source

Best practice: Always subscribe to CustomerInfo updates via PurchasesDelegate and don't rely solely on the purchase() callback for entitlement confirmation. This is your safety net for all of these edge cases.


Eligibility Checking

Introductory Offer Eligibility

For introductory offers (free trials), the Purchases SDK exposes checkTrialOrIntroductoryEligibility. This gives you a best-effort result based on RevenueCat's purchase history for the user — it's not definitive, because the final determination is always made by the native payment sheet. Source

Note: This method is only meaningful on iOS. Calling it on cross-platform SDKs (React Native, Flutter) will not return valid results on Android. Source

Promotional Offer Eligibility

For promotional offers, the getPromotionalOffer call itself is the eligibility check — if the user is ineligible or the offer doesn't exist, it throws before you get a signed discount object. There's no separate pre-check method. The docs note that getPromotionalOffer is the recommended approach for Subscription Offer eligibility. Source

Win-Back Eligibility

Win-back offer eligibility is entirely Apple-managed based on your configured criteria. Your app can't programmatically query whether a user is eligible — Apple handles targeting and surfacing. Your role is to handle the PurchasesDelegate message when Apple delivers it. Source


Tracking Offers in RevenueCat Charts

Once offers are live, RevenueCat Charts lets you filter and segment by offer identifier across all chart types. This is how you measure whether your retention campaigns are actually moving MRR or just cannibalizing full-price renewals.

The Offer filter/segment attribute maps offer types to identifiers like this: Source

Offer Type Example Identifier
App Store Win-Back Offers winback_monthly_offer
App Store Promotional Offers power_user_promo_offer
App Store Offer Codes black_friday_discount

Each chart in the RevenueCat dashboard (MRR, Active Subscriptions, Revenue, etc.) supports segmenting by these offer identifiers. The Subscription Status chart's Win-Back Offer state is defined as: "An offer that a customer with an expired subscription received to win them back." Source

For Customer Lists: The latest_offer field on each customer record captures the identifier of the most recent offer used. This lets you build targeted lists — for example, customers who redeemed your win-back offer but haven't renewed since. Source


Debugging Quick Reference

Symptom Likely Cause Fix
postOfferIdSignature error Invalid/missing IAP Key, wrong offer ID Re-upload .p8, verify identifier matches App Store Connect exactly
postOfferIdMissingOffersInResponse Offer not found on backend Check offer was saved in ASC, wait up to 24h for propagation
INVALID_CREDENTIALS during purchase API Key or shared secret misconfigured Verify credentials in RevenueCat dashboard Source
Redemption sheet never appears (iOS 18.2.x) StoreKit bug with fullScreen modal Switch to .overFullScreen (UIKit) or sheet() (SwiftUI) Source
Win-back redemptions missing from RC Server notifications not configured Set RevenueCat as Apple S2S notification endpoint Source
Offer codes show $0 revenue Known limitation on initial purchases Expected; renewals tracked correctly Source
Purchase returns "canceled" unexpectedly iOS 18.3.1–18.5 bug (resolved) Subscribe to CustomerInfo delegate as fallback Source

Implementation Checklist

Before going to production with any Apple subscription offer:

  • [ ] In-App Purchase Key (.p8) uploaded to RevenueCat with correct Issuer ID
  • [ ] Credential validation shows green in RevenueCat dashboard
  • [ ] Offer created and saved in App Store Connect (wait 24h for propagation)
  • [ ] Apple App Store Server Notifications pointing to RevenueCat (win-back required, recommended for all)
  • [ ] SDK version ≥ 5.x for win-back offers; ≥ 3.8.0 for offer codes
  • [ ] CustomerInfo listener implemented — don't rely solely on purchase callbacks
  • [ ] Full-screen modals replaced with sheet modals if presenting any StoreKit UI on iOS 18.2.x targets
  • [ ] Revenue Charts filtered by offer identifier after launch to validate performance

Sources