
Win Back Offers
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates the complete win-back offer flow for churned subscribers with StoreKit Message API handling, eligibility checks, and offer sheet presentation.
About
Generates a win-back flow for recovering churned subscribers using StoreKit 2 win-back offer APIs, Message API handling, and fallback promotional offers for older OS versions. A developer uses it when implementing win-back or re-engagement campaigns for lapsed subscribers.
- StoreKit 2 win-back offers with eligibility checks
- Message API handling plus older-OS fallback offers
Win Back Offers by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill win-back-offersAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates the complete win-back offer flow for churned subscribers with StoreKit Message API handling, eligibility checks, and offer sheet presentation.
Files
Win-Back Offers Generator
Generate a complete win-back flow for recovering churned subscribers using StoreKit 2's win-back offer APIs (iOS 18+), Message API handling, and fallback promotional offers for older OS versions.
When This Skill Activates
Use this skill when the user:
- Asks to "win back" or "re-engage" churned subscribers
- Mentions "lapsed subscribers" or "subscriber recovery"
- Wants to implement "win-back offers" (iOS 18+)
- Asks about "subscription churn" reduction
- Mentions "Message API" for subscription offers
- Wants to reduce subscriber attrition
Minimum Eligibility
Win-back offers involve meaningful engineering and ASC configuration. Check these thresholds before proceeding:
| Churned Subscribers | Recommendation |
|---|---|
| Under 50 | Not worth the setup effort. Use personal email outreach instead — write individual messages to churned users. Focus energy on preventing churn (improve onboarding, engagement). |
| 50-500 | Implement basic win-back via promotional offers (simpler than full win-back flow). Use offer codes distributed via email. |
| 500+ | Full win-back implementation justified. Native iOS 18+ win-back offers, Message API handling, automated pipeline. |
If you don't know your churn numbers: Check App Store Connect > Subscriptions > Retention metrics. If your total subscriber base is under 200, you likely don't have enough churn volume to justify this skill yet.
Pre-Generation Checks
1. Project Context Detection
- [ ] Check for existing StoreKit implementation
- [ ] Check deployment target (iOS 18+ for native win-back, iOS 16+ for promotional offer fallback)
- [ ] Look for existing subscription management code
- [ ] Check if
subscription-offersskill was already used - [ ] Check churn volume — is the number of churned subscribers worth automating?
2. Conflict Detection
Glob: **/*WinBack*.swift, **/*Reengag*.swift
Grep: "winBackOffers" or "Message.messages" or "presentWinBackOffer"Configuration Questions
Ask user via AskUserQuestion:
1. Win-back approach
- Native win-back offers only (iOS 18+ required)
- Promotional offer fallback (supports iOS 16+)
- Both (recommended)
2. Message handling
- Handle App Store messages in-app (recommended)
- Let system handle messages
- Custom message presentation
3. Analytics integration
- Basic (print logging)
- Custom analytics protocol
- Specific SDK (specify which)
Generation Process
Step 1: Read Templates
Read templates.md for all win-back implementation code.
Step 2: Create Core Files
1. WinBackOfferManager.swift — Win-back offer detection, eligibility, and presentation 2. WinBackMessageHandler.swift — StoreKit Message API integration for in-app offer display 3. WinBackEligibility.swift — Churn detection and eligibility logic 4. WinBackOfferView.swift — Custom win-back offer presentation UI
Step 3: Create Optional Files
5. WinBackAnalytics.swift — Track win-back funnel metrics 6. WinBackFallback.swift — Promotional offer fallback for iOS < 18
Step 4: Determine File Location
- If Sources/Store/ exists → Sources/Store/WinBack/
- If Store/ exists → Store/WinBack/
- Otherwise → WinBack/Output Format
Files Created
Store/WinBack/
├── WinBackOfferManager.swift # Core win-back logic
├── WinBackMessageHandler.swift # Message API handling
├── WinBackEligibility.swift # Churn detection
├── WinBackOfferView.swift # Custom offer UI
├── WinBackAnalytics.swift # Funnel tracking (optional)
└── WinBackFallback.swift # iOS < 18 fallback (optional)Integration Steps
App Entry Point — Message Handling:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.storeMessagesTask { message in
// Handle win-back messages from App Store
await WinBackMessageHandler.shared.handle(message)
}
}
}
}Check Win-Back Eligibility:
let manager = WinBackOfferManager()
let winBackOffer = await manager.checkWinBackAvailability(
productID: "com.app.subscription.monthly"
)
if let offer = winBackOffer {
// Show win-back UI
showWinBackSheet(offer: offer)
}Present Win-Back Offer:
// Native (iOS 18+)
SubscriptionStoreView(groupID: groupID)
.preferredSubscriptionOffer { product, subscription, offers in
offers.first { $0.type == .winBack }
}Testing Instructions
1. Sandbox Account: Create a subscriber, cancel, wait for expiry 2. StoreKit Testing: Use Transaction Manager to simulate churn 3. Message Testing: Test App Store message presentation 4. Verify flow: Churned → Offer shown → Purchase → Active subscriber
App Store Connect Setup
1. Go to Subscriptions > Your Group > Win-Back Offers 2. Create offer with pricing and duration 3. Set eligibility criteria (days since churn, previous subscription duration) 4. Configure offer priority if multiple offers exist
References
- templates.md — All production Swift templates
- Related:
generators/subscription-offers— All offer types including win-back - Related:
generators/paywall-generator— Full paywall implementation - Related:
app-store/marketing-strategy— Win-back campaign planning
Win-Back Offers Templates
Production-ready Swift code for win-back offer implementation.
WinBackOfferManager.swift
import StoreKit
/// Manages win-back offer detection, eligibility, and presentation
@Observable
final class WinBackOfferManager {
static let shared = WinBackOfferManager()
private(set) var availableWinBackOffer: WinBackOffer?
private(set) var isCheckingEligibility = false
// MARK: - Check Availability
/// Checks if a win-back offer is available for the given product
func checkWinBackAvailability(productID: String) async -> WinBackOffer? {
isCheckingEligibility = true
defer { isCheckingEligibility = false }
guard let product = try? await Product.products(for: [productID]).first,
let subscription = product.subscription else {
return nil
}
// iOS 18+ native win-back offers
if #available(iOS 18.0, macOS 15.0, *) {
let winBackOffers = await subscription.winBackOffers
if let offer = winBackOffers.first {
let winBack = WinBackOffer(
product: product,
offer: offer,
source: .native
)
availableWinBackOffer = winBack
return winBack
}
}
// Fallback: Check if eligible for promotional offer as win-back
if await isChurnedSubscriber(subscription: subscription) {
if let promoOffer = subscription.promotionalOffers.first(
where: { $0.id?.contains("winback") == true }
) {
let winBack = WinBackOffer(
product: product,
offer: promoOffer,
source: .promotional
)
availableWinBackOffer = winBack
return winBack
}
}
return nil
}
// MARK: - Purchase with Win-Back Offer
func purchaseWithWinBack(_ winBack: WinBackOffer) async throws -> Transaction? {
var options: Set<Product.PurchaseOption> = []
if winBack.source == .promotional, let offerID = winBack.offer.id {
// Promotional offers require server-side signature
// See OfferSignatureProvider in subscription-offers skill
options.insert(.promotionalOffer(
offerID: offerID,
keyID: "YOUR_KEY_ID",
nonce: UUID(),
signature: Data(), // From your server
timestamp: Int(Date().timeIntervalSince1970)
))
}
let result = try await winBack.product.purchase(options: options)
switch result {
case .success(let verification):
guard case .verified(let transaction) = verification else {
throw WinBackError.verificationFailed
}
await transaction.finish()
availableWinBackOffer = nil
return transaction
case .userCancelled:
return nil
case .pending:
return nil
@unknown default:
return nil
}
}
// MARK: - Private
private func isChurnedSubscriber(
subscription: Product.SubscriptionInfo
) async -> Bool {
guard let statuses = try? await subscription.status else {
return false
}
let hasExpired = statuses.contains { $0.state == .expired }
let hasActive = statuses.contains {
$0.state == .subscribed || $0.state == .inGracePeriod
}
return hasExpired && !hasActive
}
}
// MARK: - Models
struct WinBackOffer: Identifiable {
let id = UUID()
let product: Product
let offer: Product.SubscriptionOffer
let source: WinBackSource
enum WinBackSource {
case native // iOS 18+ win-back offer
case promotional // Promotional offer used as win-back
}
var displayPrice: String {
offer.displayPrice
}
var periodDescription: String {
let unit = offer.period.unit
let value = offer.period.value
switch unit {
case .day: return value == 1 ? "day" : "\(value) days"
case .week: return value == 1 ? "week" : "\(value) weeks"
case .month: return value == 1 ? "month" : "\(value) months"
case .year: return value == 1 ? "year" : "\(value) years"
@unknown default: return "\(value) periods"
}
}
var isFree: Bool {
offer.paymentMode == .freeTrial
}
}
enum WinBackError: LocalizedError {
case verificationFailed
case noOfferAvailable
case purchaseFailed(Error)
var errorDescription: String? {
switch self {
case .verificationFailed: "Transaction verification failed"
case .noOfferAvailable: "No win-back offer available"
case .purchaseFailed(let error): "Purchase failed: \(error.localizedDescription)"
}
}
}WinBackMessageHandler.swift
import StoreKit
/// Handles App Store messages for win-back offers
/// Attach to your app's root view using .storeMessagesTask
@Observable
final class WinBackMessageHandler {
static let shared = WinBackMessageHandler()
private(set) var pendingMessage: Message?
private(set) var showingOffer = false
/// Handle an incoming App Store message
/// Call this from .storeMessagesTask on your root view
@MainActor
func handle(_ message: Message) async {
// Store the message for custom handling
pendingMessage = message
switch message.reason {
case .winBackOffer:
// Win-back offer from App Store
showingOffer = true
// Let the system present its default UI
// Or suppress and show your custom UI:
// message.display(in: .current)
case .priceIncreaseConsent:
// Price increase consent — always let system handle
break
case .billingIssue:
// Billing issue — always let system handle
break
default:
break
}
}
/// Dismiss the current offer
func dismissOffer() {
showingOffer = false
pendingMessage = nil
}
}WinBackEligibility.swift
import StoreKit
/// Determines win-back eligibility based on subscription history
struct WinBackEligibility {
struct ChurnInfo {
let lastSubscriptionEndDate: Date?
let daysSinceChurn: Int
let previousSubscriptionDuration: Int // in days
let totalPreviousSpend: Decimal?
let churnReason: ChurnReason?
}
enum ChurnReason {
case voluntaryCancellation
case billingIssue
case priceIncrease
case unknown
}
/// Analyze churn details for a subscription group
static func analyzeChurn(groupID: String) async -> ChurnInfo? {
var lastEndDate: Date?
var churnReason: ChurnReason?
// Check transaction history
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
transaction.subscriptionGroupID == groupID else {
continue
}
if let expirationDate = transaction.expirationDate {
if lastEndDate == nil || expirationDate > lastEndDate! {
lastEndDate = expirationDate
// Determine churn reason from revocation
if transaction.revocationDate != nil {
churnReason = .voluntaryCancellation
}
}
}
}
guard let endDate = lastEndDate else {
return nil // Never subscribed
}
let daysSinceChurn = Calendar.current.dateComponents(
[.day], from: endDate, to: Date()
).day ?? 0
guard daysSinceChurn > 0 else {
return nil // Still active
}
return ChurnInfo(
lastSubscriptionEndDate: endDate,
daysSinceChurn: daysSinceChurn,
previousSubscriptionDuration: 0, // Calculate from transaction history
totalPreviousSpend: nil, // Calculate from transaction history
churnReason: churnReason ?? .unknown
)
}
/// Recommend win-back offer aggressiveness based on churn info
static func recommendedOfferTier(for churn: ChurnInfo) -> OfferTier {
switch churn.daysSinceChurn {
case 0...14:
return .gentle // 25-30% off, they just left
case 15...60:
return .moderate // 40-50% off, need convincing
case 61...180:
return .aggressive // 60%+ off or free month
default:
return .lastChance // Free trial restart or deep discount
}
}
enum OfferTier: String {
case gentle = "25-30% discount"
case moderate = "40-50% discount"
case aggressive = "60%+ discount or free month"
case lastChance = "Extended free trial"
}
}WinBackOfferView.swift
import SwiftUI
import StoreKit
/// Custom win-back offer presentation
struct WinBackOfferView: View {
let offer: WinBackOffer
let onAccept: () async -> Void
let onDecline: () -> Void
@State private var isPurchasing = false
var body: some View {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "heart.circle.fill")
.font(.system(size: 56))
.foregroundStyle(.accent)
.symbolEffect(.pulse, options: .repeating)
Text("Welcome Back!")
.font(.title.weight(.bold))
Text("We've been making \(offer.product.displayName) even better while you were away.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// What's New section
VStack(alignment: .leading, spacing: 12) {
Text("What's New")
.font(.headline)
// TODO: Customize with your actual new features
FeatureRow(icon: "sparkles", text: "Improved performance")
FeatureRow(icon: "paintbrush", text: "Fresh new design")
FeatureRow(icon: "star", text: "New premium features")
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.1))
}
// Offer details
VStack(spacing: 8) {
if offer.isFree {
Text("Try Again Free")
.font(.title3.weight(.semibold))
Text("Get \(offer.periodDescription) free, then \(offer.product.displayPrice)/\(offer.product.subscription?.subscriptionPeriod.unit.localizedDescription ?? "month")")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("Special Return Offer")
.font(.title3.weight(.semibold))
Text("Just \(offer.displayPrice) for your first \(offer.periodDescription)")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
// CTA buttons
VStack(spacing: 12) {
Button {
isPurchasing = true
Task {
await onAccept()
isPurchasing = false
}
} label: {
if isPurchasing {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text(offer.isFree ? "Start Free Trial" : "Claim Offer")
.frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(isPurchasing)
Button("Not Now", action: onDecline)
.foregroundStyle(.secondary)
}
// Legal
Text("Cancel anytime. Subscription auto-renews.")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(24)
}
}
private struct FeatureRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(.accent)
.frame(width: 24)
Text(text)
.font(.subheadline)
}
}
}
// MARK: - Subscription Period Helper
private extension Product.SubscriptionPeriod.Unit {
var localizedDescription: String {
switch self {
case .day: "day"
case .week: "week"
case .month: "month"
case .year: "year"
@unknown default: "period"
}
}
}WinBackAnalytics.swift
import Foundation
/// Tracks win-back offer funnel metrics
protocol WinBackAnalyticsProvider {
func trackEvent(_ event: WinBackAnalyticsEvent)
}
enum WinBackAnalyticsEvent {
case eligibilityChecked(productID: String, eligible: Bool)
case offerShown(productID: String, offerType: String, source: String)
case offerAccepted(productID: String, offerType: String)
case offerDeclined(productID: String, offerType: String)
case purchaseCompleted(productID: String, revenue: Decimal)
case purchaseFailed(productID: String, error: String)
var name: String {
switch self {
case .eligibilityChecked: "winback_eligibility_checked"
case .offerShown: "winback_offer_shown"
case .offerAccepted: "winback_offer_accepted"
case .offerDeclined: "winback_offer_declined"
case .purchaseCompleted: "winback_purchase_completed"
case .purchaseFailed: "winback_purchase_failed"
}
}
var parameters: [String: String] {
switch self {
case .eligibilityChecked(let productID, let eligible):
["product_id": productID, "eligible": "\(eligible)"]
case .offerShown(let productID, let offerType, let source):
["product_id": productID, "offer_type": offerType, "source": source]
case .offerAccepted(let productID, let offerType):
["product_id": productID, "offer_type": offerType]
case .offerDeclined(let productID, let offerType):
["product_id": productID, "offer_type": offerType]
case .purchaseCompleted(let productID, let revenue):
["product_id": productID, "revenue": "\(revenue)"]
case .purchaseFailed(let productID, let error):
["product_id": productID, "error": error]
}
}
}
/// Default print-based analytics (replace with your analytics SDK)
struct PrintWinBackAnalytics: WinBackAnalyticsProvider {
func trackEvent(_ event: WinBackAnalyticsEvent) {
print("[WinBack Analytics] \(event.name): \(event.parameters)")
}
}