Subscription Churn Prevention Pipeline with RevenueCat Webhooks and Antigravity AI
A complete implementation of an AI-powered churn prevention pipeline triggered by RevenueCat webhook events — covering event processing, churn scoring with Gemini, and automated win-back sequences via Cloudflare Workers, Supabase, and Resend, with numbers grounded in 50M+ downloads of indie app operations.
I've been running apps as an indie developer since 2013, and for the first few years I genuinely believed that growing new downloads was the path to revenue growth. It wasn't until my apps crossed 50 million cumulative downloads that the math of subscription retention finally clicked — cutting churn by 1% has a far larger long-term revenue impact than a 3% bump in new installs.
Subscription app revenue is determined by the product of acquisition and retention. The reason indie developers tend to put churn prevention off isn't indifference — it's the perception that the infrastructure is hard to build. I ran AdMob-driven free apps for most of my career, but when I started adding subscription tiers to one of my wallpaper apps, I built churn detection and automated win-back on top of RevenueCat using code that Antigravity generated.
The implementation turned out to be far more approachable than I expected. What follows is the entire flow, with working code and the operational judgment calls that came from actually shipping it.
Each layer has specific design decisions worth understanding. Let's go through them.
The Churn Math, From an Indie 50M-Download Vantage Point
Why does "reduce churn by 1pt" beat "grow installs by 3%"? The math is worth seeing on the page:
Annual revenue $\approx M \times 12 \times \frac{1}{c}$ where $M$ is MRR and $c$ is monthly churn rate (the average subscriber lifetime is the reciprocal of churn).
If churn drops from 5% to 4%, average lifetime stretches from 20 months to 25 months — a 25% LTV improvement, compounding every month.
A 3% acquisition lift, with churn unchanged, lifts revenue once and stays flat thereafter.
In my own apps, new installs scale with marketing spend, but every percentage point of churn I claw back keeps paying me back every month, forever. That compounding is the structural reason churn work is undervalued.
As a concrete reference point: across the first three months of running subscriptions in one of my wallpaper apps with zero retention work, monthly churn settled around 8.2%. After rolling out the pipeline below — immediate email to HIGH-risk users, a 50%-off offer at the 48-hour mark, a final win-back at day 7 — monthly churn fell to 5.4%, and LTV improved roughly 1.5x. The infrastructure cost throughout was effectively zero (Cloudflare Workers + Supabase free tier + Resend's 3,000 free monthly emails).
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦End-to-end production pipeline (RevenueCat Webhook → Cloudflare Workers → Gemini Flash → Resend) with working TypeScript code you can deploy this weekend
✦Numbers from running indie apps that crossed 50 million downloads — including why a 1% churn reduction outperforms a 3% acquisition lift
✦Seven production landmines not covered in official docs, each with the exact code fix from real subscription operations
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The critical timing insight: CANCELLATION is your best intervention window. The user has indicated intent to leave but still has active access. If you wait for EXPIRATION, the user has already been gone for days or weeks. Acting at cancellation, while they're still in the product, is where win-back campaigns succeed.
Step 2: Webhook Receiver (Cloudflare Workers)
// src/webhook.tsimport { createClient } from "@supabase/supabase-js";interface RevenueCatWebhookEvent { event: { id: string; type: string; app_user_id: string; product_id: string; period_type: string; purchased_at_ms: number; expiration_at_ms: number; environment: "SANDBOX" | "PRODUCTION"; cancel_reason?: string; }; api_version: string;}export default { async fetch(request: Request, env: Env): Promise<Response> { if (request.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } // Validate the Authorization header from RevenueCat const authHeader = request.headers.get("Authorization"); if (authHeader !== `Bearer ${env.REVENUECAT_WEBHOOK_SECRET}`) { return new Response("Unauthorized", { status: 401 }); } const body = await request.json() as RevenueCatWebhookEvent; const event = body.event; // Drop sandbox events in production if (event.environment === "SANDBOX" && env.ENVIRONMENT === "production") { return new Response("OK (sandbox ignored)", { status: 200 }); } const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); // Idempotency: rely on the DB unique constraint on event_id const { error: insertError } = await supabase.from("subscription_events").insert({ event_id: event.id, user_id: event.app_user_id, event_type: event.type, product_id: event.product_id, cancel_reason: event.cancel_reason ?? null, expired_at: event.expiration_at_ms ? new Date(event.expiration_at_ms).toISOString() : null, created_at: new Date().toISOString(), }); if (insertError && insertError.code === "23505") { // Duplicate event_id: already processed, return 200 return new Response("OK (duplicate)", { status: 200 }); } // Push churn risk events to a queue for downstream scoring const CHURN_RISK_EVENTS = ["CANCELLATION", "BILLING_ISSUE", "PRODUCT_CHANGE"]; if (CHURN_RISK_EVENTS.includes(event.type)) { await env.CHURN_RISK_QUEUE.send({ user_id: event.app_user_id, event_type: event.type, cancel_reason: event.cancel_reason, expires_at: event.expiration_at_ms, }); } return new Response("OK", { status: 200 }); },};
Step 3: Churn Score Calculation (AI Analysis)
A queue worker calculates the churn score. The code below was scaffolded by Antigravity and then tuned against real user behavior:
// src/churn-scorer.tsimport { createClient } from "@supabase/supabase-js";interface ChurnInput { user_id: string; event_type: string; cancel_reason?: string; expires_at?: number;}interface ChurnScore { score: number; // 0.0 - 1.0 (higher = more at risk) risk_level: "LOW" | "MEDIUM" | "HIGH"; win_back_strategy: string; recommended_action: string;}async function fetchUserBehavior( supabase: ReturnType<typeof createClient>, user_id: string,): Promise<object> { // Past event history const { data: events } = await supabase .from("subscription_events") .select("event_type, created_at") .eq("user_id", user_id) .order("created_at", { ascending: false }) .limit(50); // Recent app usage (populate this from your own analytics) const { data: usage } = await supabase .from("app_usage") .select("session_count, last_active_at, feature_usage") .eq("user_id", user_id) .single(); return { event_history: events ?? [], session_count_last_30d: usage?.session_count ?? 0, last_active_at: usage?.last_active_at, feature_usage: usage?.feature_usage ?? {}, };}export async function calculateChurnScore( input: ChurnInput, env: Env,): Promise<ChurnScore> { const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); const behavior = await fetchUserBehavior(supabase, input.user_id); const prompt = `You are a churn analyst for subscription apps. Compute a churn risk score from the user behavior data below.[Trigger Event]- Event type: ${input.event_type}- Cancel reason: ${input.cancel_reason ?? "unknown"}- Subscription expires: ${input.expires_at ? new Date(input.expires_at).toISOString() : "unknown"}[User Behavior]${JSON.stringify(behavior, null, 2)}Respond in this JSON shape:{ "score": 0.0 to 1.0 (higher = greater churn risk), "risk_level": "LOW" | "MEDIUM" | "HIGH", "win_back_strategy": "Recommended win-back strategy in one sentence", "recommended_action": "Concrete action: discount, perks, contact timing, etc."}Scoring guidance:- Cancel reason "too expensive" → price sensitive, discount offers work- Low last-30d session count → user didn't get value, feature highlights work- Previously cancelled and resubscribed → high win-back success rate- Billing failure → prioritize payment-method update prompt`; const response = await fetch( "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", { method: "POST", headers: { "Content-Type": "application/json", "X-Goog-Api-Key": env.GEMINI_API_KEY, }, body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: prompt }] }], }), }, ); const data = await response.json() as { candidates: Array<{ content: { parts: Array<{ text: string }> } }> }; const text = data.candidates[0].content.parts[0].text; const start = text.indexOf("{"); const end = text.lastIndexOf("}") + 1; return JSON.parse(text.slice(start, end)) as ChurnScore;}
In my experience, session_count and feature_usage are the two variables that move scoring accuracy the most. A user with near-zero sessions in the last 30 days who never touched a paywalled feature (export, PDF, ad-free) is reliably HIGH. A user who opens the app daily but cancelled is almost always a price-sensitivity case — they need a discount, not another feature highlight.
Step 4: Win-back Sequence (Resend Email)
Different sequences fire by risk level:
// src/winback-emailer.tsimport { Resend } from "resend";interface WinbackContext { user_id: string; email: string; name: string; app_name: string; support_url: string; resubscribe_url: string; churn_score: ChurnScore;}const EMAIL_SEQUENCES: Record<string, Array<{ delay_hours: number; template: string }>> = { // HIGH: immediate + discount offer within 72 hours + final attempt HIGH: [ { delay_hours: 0, template: "high_risk_immediate" }, { delay_hours: 48, template: "high_risk_discount_offer" }, { delay_hours: 168, template: "final_winback" }, // 7 days later ], // MEDIUM: 24h value highlight + 5d tips MEDIUM: [ { delay_hours: 24, template: "medium_risk_value_highlight" }, { delay_hours: 120, template: "medium_risk_tips" }, ], // LOW: soft followup one week later LOW: [ { delay_hours: 168, template: "low_risk_soft_followup" }, ],};const EMAIL_TEMPLATES = { high_risk_immediate: (ctx: WinbackContext) => ({ subject: `Thank you for using ${ctx.app_name}`, html: ` <p>Hi ${ctx.name ?? "there"},</p> <p>We've processed your cancellation. If pricing was the concern, we'd love to hear from you — there may be a plan that fits your situation.</p> <p><a href="${ctx.support_url}">Contact support</a></p> `, }), high_risk_discount_offer: (ctx: WinbackContext) => ({ subject: `A special offer to continue with ${ctx.app_name}`, html: ` <p>Hi ${ctx.name ?? "there"},</p> <p>If you're still thinking about coming back, here's <strong>50% off your first month</strong>.</p> <p><a href="${ctx.resubscribe_url}?discount=50&user=${ctx.user_id}">Resume at the special price</a></p> <p>This offer is valid for the next 72 hours.</p> `, }),};export async function executeWinbackSequence( context: WinbackContext, env: Env,): Promise<void> { const resend = new Resend(env.RESEND_API_KEY); const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY); const sequence = EMAIL_SEQUENCES[context.churn_score.risk_level] ?? []; // Guard: skip if already in an active win-back sequence in the last 30 days const { count } = await supabase .from("winback_schedules") .select("*", { count: "exact", head: true }) .eq("user_id", context.user_id) .gte("created_at", new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()); if ((count ?? 0) > 0) { console.log(`Skipping winback for ${context.user_id} — sequence already active`); return; } for (const step of sequence) { if (step.delay_hours === 0) { const template = EMAIL_TEMPLATES[step.template as keyof typeof EMAIL_TEMPLATES]; if (template) { const { subject, html } = template(context); await resend.emails.send({ from: `${context.app_name} <noreply@${env.EMAIL_DOMAIN}>`, to: context.email, subject, html, }); } } else { await env.WINBACK_EMAIL_QUEUE.send( { user_id: context.user_id, email: context.email, name: context.name, template: step.template, app_name: context.app_name, }, { delaySeconds: step.delay_hours * 3600 }, ); } await supabase.from("winback_schedules").insert({ user_id: context.user_id, template: step.template, scheduled_at: new Date(Date.now() + step.delay_hours * 3600 * 1000).toISOString(), status: step.delay_hours === 0 ? "sent" : "scheduled", }); }}
Step 5: Effectiveness Tracking
Measuring win-back success means tracking whether churned users resubscribed after receiving the sequence:
winback_rate is the single most important monthly dashboard number. In my wallpaper app, the immediate email to HIGH-risk users converts at roughly 8–12%, the 48-hour discount offer at 3–6%, and the combined sequence brings about 10% of churned users back. As a rough benchmark, 100 monthly churn events recover about 10 subscribers — at a $5/month plan that's roughly $600 of recovered annual revenue, growing with the install base.
Seven Production Landmines Not in the Official Docs
The official RevenueCat docs walk you through the happy path. Here are the seven landmines I actually stepped on running this in production, each with the concrete fix.
1. Sandbox events distort your production KPIs
When you're testing subscriptions with App Store Connect sandbox testers, you'll see a flood of SANDBOXCANCELLATION events. If you aggregate those into production metrics, your apparent churn rate spikes. Filtering on event.environment is non-negotiable:
if (event.environment === "SANDBOX" && env.ENVIRONMENT === "production") { return new Response("OK (sandbox ignored)", { status: 200 });}
2. Webhook duplicate delivery sends the same user three emails
RevenueCat retries failed deliveries. Add a unique constraint on event_id in subscription_events. I missed this on my first deployment and ended up sending one apologetic user three copies of the same "special offer" email — exactly the experience you most want to avoid:
ALTER TABLE subscription_events ADD CONSTRAINT subscription_events_event_id_key UNIQUE (event_id);
3. Routing BILLING_ISSUE through win-back backfires
Sending "50% off your first month" to a user whose card just expired reads as "wait, I didn't cancel — what is this?" BILLING_ISSUE belongs in a separate payment-update flow, never the marketing pipeline.
4. The "creepy / too pushy" boundary on immediate emails
In my apps, the sweet spot for the first email is 3–6 hours after CANCELLATION. At literally 0 hours, the email arrives before the user has closed the cancellation screen and feels surveillance-y. Wait too long and the decision has hardened. The 3–6 hour window is when the user is still mentally engaged but has had time to step away from the app.
5. The discount goes in email #2, not email #1
If you offer the discount in the first email, users learn that cancelling → waiting → discount is a reliable pattern. In my data, putting the discount in email #2 (48 hours later) vs. email #1 produced a roughly 15% higher long-term ARPPU. Lead with gratitude, follow with the offer.
6. Cloudflare Queues delays have up to 15 minutes of jitter
delaySeconds is best-effort. Actual delivery can be several minutes to 15+ minutes late depending on queue load. Don't rely on it for "exactly 48 hours later." Record scheduled_at in the DB and run a cron worker that picks up rows where now() >= scheduled_at AND status = 'scheduled'. Queues handle the dispatch, the DB handles the SLA.
7. Resend's free tier hits a hard wall at 3,000 emails
The free tier doesn't degrade gracefully — emails stop being delivered at the 3,000 boundary. Send a Slack alert at 2,500 sent so you have time to upgrade to Resend Pro ($20/month) before the next win-back batch silently disappears.
Generating This Pipeline With Antigravity
A few notes on the workflow I actually use when generating a pipeline at this scope with Antigravity:
Lead with the architecture, not the code. Drop the ASCII diagram above plus the table schemas into a single file and let Antigravity work through it in Plan mode first. Settling the data model and responsibility boundaries before code is the single biggest reduction in rework I've found.
Split webhook / scorer / emailer into separate generation tasks. Asking one thread to produce all three files invites subtle type drift between functions. Generate src/webhook.ts, src/churn-scorer.ts, and src/winback-emailer.ts in separate tasks with shared interfaces.
After generation, ask the model to list ten ways this could fail in production. Duplicate deliveries, sandbox contamination, rate limits, queue jitter — most of the items in the "Seven Landmines" section above started as outputs from this prompt and then survived contact with real users.
Fire test events through the RevenueCat sandbox. The webhook tester is useful, but actually subscribing → cancelling → resubscribing through a sandbox tester gives you the complete event sequence and surfaces handlers you missed.
Antigravity's generated code is close to production-ready, but the operational edge cases — the ones that only become obvious after running an app at scale — are still the part you have to add yourself. After 50 million downloads, that's the part I trust myself with and the structure I delegate to the AI.
Closing
A RevenueCat Webhook → Cloudflare Workers → AI scoring → Resend pipeline is a churn-prevention infrastructure that an indie developer can realistically run. The infrastructure cost stays near zero until you're at meaningful scale.
The three things that matter: log every event, intervene early at CANCELLATION rather than EXPIRATION, and let the AI score determine how aggressive the win-back sequence is. Treating every churning user identically is the mistake that keeps win-back rates in the low single digits.
I'm still iterating on this on my own apps, so if you're growing a subscription business as an indie developer and try a version of this, I'd love to hear what worked. As a concrete next step: load the last 30 days of CANCELLATION events into Supabase, send a single immediate email per HIGH-risk user, and measure the first number. That alone tends to recover a few subscribers in the first week.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.