Win-back offers on Google Play: recovering cancelled subscribers server-side
August 27, 2026 · Tierux Team
A subscriber who cancels doesn't lose access immediately — they paid for the current period, so they keep it until expiry. That gap between "cancelled" and "expired" is exactly the window a win-back offer targets: a discounted price shown to a subscriber who is still in your app, still has access, but has already decided to leave. Get the offer in front of them before access actually lapses, and some meaningful fraction come back. This is a Google Play capability specifically — there is no StoreKit 2 equivalent, and we'll get into why that distinction matters below.
Identifying candidates: cancelled, not expired
The entitlement state that identifies a win-back candidate already exists on every cancellation — it's the same status: 'cancelled' with active left untouched that a Real-Time Developer Notification produces the moment Play reports a SUBSCRIPTION_CANCELED event:
case 3: // SUBSCRIPTION_CANCELED — auto-renew off; access remains until expiry
return { status: 'cancelled', winback: 'set' };The winback: 'set' intent doesn't unconditionally arm a save-sheet flag — it only does so if the product actually has a configured win-back offer, resolved through a small shared helper used by both the Google and Apple notification processors:
export async function resolveWinbackPending( store: EntitlementStore, intent: 'set' | 'clear' | undefined, appId: string, productId: string, ): Promise{ if (intent === 'clear') return false; if (intent !== 'set') return undefined; const offer = await store.getWinbackOffer(appId, productId, 'cancellation'); return !!offer; }
No offer configured for that product means no flag armed and no save sheet shown — the resolver stays a no-op rather than pretending an offer exists. If the subscriber later reactivates, renews, or the subscription fully expires, the flag is explicitly cleared, so a stale win-back prompt never lingers past the moment it's relevant.
Configuring the offer
Offers themselves are created through Tierux's MCP tooling (or the equivalent dashboard flow), mapped to a product and an entitlement, with a preview-then-confirm step before anything is persisted — the same confirm-token pattern used for every other write-capable MCP tool:
const preview = {
action: 'create', // or 'update', if one already exists
name: 'Win it back',
productId: 'pro_monthly',
entitlementId: 'pro', // derived from the product's mapping
discountPrice: '$4.99',
trigger: 'cancellation',
};
// "Creates a cancellation win-back offer \"Win it back\" on pro_monthly
// at $4.99 (entitlement \"pro\"). Confirm to proceed."The entitlement id is derived from the product's existing mapping rather than taken as free-form input, and re-derived identically at confirm time, so a swapped product between preview and confirm invalidates the token rather than silently applying the offer to the wrong entitlement.
Applying the offer: why this is Play-specific
This is where the platform boundary is real, not just a marketing caveat. Google Play subscriptions support multiple concurrent offers per base plan — introductory, promotional, and win-back — each carrying its own offerToken that the client passes into the Play Billing launch flow. The Android SDK selects the right one from the set of offers Play returns for a product:
// Rule 1: exact offerId match
if (winbackOfferId != null) {
val exact = candidates.firstOrNull { it.offerId == winbackOfferId }
if (exact != null) return exact.offerToken
}
// Rule 2: single offer tagged "winback"
val tagMatches = candidates.filter { WINBACK_OFFER_TAG in it.offerTags }
if (tagMatches.size == 1) return tagMatches[0].offerToken
// Rule 3: single non-base offer
// Rule 4: ambiguous or none — nullThat selected offerToken is what actually launches the discounted purchase flow on Play. StoreKit 2 has no equivalent offer-token mechanism for this — Apple's promotional-offer and offer-code systems are shaped differently and are not wired into Tierux's win-back flow. The iOS SDK's purchase-result type does carry a placeholder case for a win-back acceptance, but it's currently a no-op:
case .winbackAccepted:
breakThat stub is deliberate, not an oversight — it exists so the shared purchase-result shape compiles across platforms, without claiming iOS actually redeems an offer through it. The entitlement-state side (cancelled-but-active, save-sheet flag arm/disarm) is genuinely shared between the Google and Apple notification processors, because that's just accurate lifecycle bookkeeping either platform can produce. The redemption side — an actual discounted re-purchase flow — is implemented for Google Play today; it is not implemented for the App Store, per Tierux's platform support matrix.
Where this fits in your plan
Win-back offers are a Pro-tier feature on Tierux, alongside AI paywall translation, layered on top of the free-tier server-side verification and entitlement sync every plan includes — see pricing for the current breakdown. If you're building the offer flow yourself, the pieces you need are: an RTDN-driven cancelled-but-active state, an offer configured per product, an offerToken resolved from Play's returned offer set, and a client purchase flow that passes that token into the billing launch call — all gated so a subscriber who never cancelled never sees the sheet.
Win-back glossary entry
The short reference definition and how it differs from a general re-engagement campaign.
RTDN vs polling
How the cancelled-but-active state that identifies win-back candidates gets set in real time.
Platform support matrix
The full per-feature, per-platform breakdown — including where Apple App Store support has limitations.
Recover cancelled subscribers before they expire
Free tier — unlimited apps, 1 paywall. No credit card, no revenue share.
Start free