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
- In App Store Connect, navigate to Users and Access → Integrations → In-App Purchase.
- Click Generate In-App Purchase Key. You get one chance to download the
.p8file — save it securely. - 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. - 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 |
⚠️ 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)")
}
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:
- Missing or invalid IAP Key — the
.p8file is either not uploaded, revoked, or the Issuer ID doesn't match. - Wrong offer identifier — the string passed as
offerIdentifierdoesn't exactly match what's in App Store Connect. - Offer not saved in App Store Connect — easy to forget the Save button after creating the offer.
- 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?()
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()
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
- [ ]
CustomerInfolistener 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
- iOS Subscription Offers (RevenueCat Docs)
- In-App Purchase Key Configuration
- Free Trials & Promo Offers Overview
- Error Handling — postOfferIdSignature and related
- Charts Overview — Offer Filtering and Segmentation
- Revenue Chart — Offer Code Revenue Limitation
- Customer Lists — latest_offer field
- Apple App Store Server Notifications
- Known Issue: iOS 18.2.x Purchase Sheet May Fail to Appear
- Known Issue: iOS 18.3.1–18.5 Canceled State After Successful Purchase
- Customer Center Configuration — Promotional Offers
- Paywall Supporting Offers
- StoreKit Known Issues Index