Why Stripe Test Mode Lies: A Solo Dev's 36-Hour Payment Infrastructure Trench Warfare
Hey everyone π I'm Usama. By day, I work as a Project Manager. By night, I'm a solo dev building a Kotlin Multiplatform (KMP) notes app called EpicNotes, targeting the "forgotten combo" of Android phones and MacBooks.
The tech stack was hard (KMP, Supabase, local-first sync). But the part that actually broke me was the payment infrastructure.
I thought wiring up Stripe for the Mac app and RevenueCat for the Android app would take a weekend. Instead, I ended up in a 36-hour debug marathon, buying my own subscriptions, refunding them, watching database rows flip, and debugging at 2 AM after a full day of meetings.
If you are an indie dev about to wire up payments, here are the 3 brutal lessons I learned so you don't have to.
1. Test Mode is a Sandbox, Not Reality
Stripe and RevenueCat test environments are great for happy paths, but they lie to you in subtle ways.
During my testing, the Android RevenueCat SDK had a blank API key that nobody noticed until I tried a real purchase with a real credit card. Test mode was perfectly happy passing simulated webhooks, masking the fact that my Android SDK was essentially flying blind.
The fix: Test with real money. Buy a $2 subscription on your own phone. Refund it. Watch your database. The peace of mind is worth the $2.
2. Webhook Retries Create "Zombie" Users
Webhooks are not guaranteed to arrive in order, and they will retry if your server takes more than a few seconds to respond.
I ran into a nightmare scenario where a customer.subscription.updated event arrived before the customer.subscription.deleted event due to network retries. My Supabase database saw the update, flipped the user back to is_pro: true, and completely ignored the subsequent deletion event. I had accidentally built a system that resurrected cancelled subscriptions into "Zombie Pro Users."
The fix: Never trust the webhook payload blindly. When a webhook arrives, use the subscription_id to make a direct API call back to Stripe/RevenueCat to fetch the live, current state of the subscription.
3. The "Hostage Paywall" Bug
I had a bug where the X button on my Mac paywall wouldn't close the window unless the Stripe checkout session actually finished. If a user opened the paywall and changed their mind, they were trapped. I had to literally force-quit the app to get out.
It sounds silly, but when you are deep in the weeds of Kotlin Multiplatform UI state and async Stripe callbacks, you lose sight of the basic UX.
The Mental Toll of the Side Hustle
Building a dual-platform SaaS on top of a day job is a feature, not a bugβit forces you to be disciplined. But it also means when the webhooks fail, you aren't just losing company time; you're losing your sleep and your time with your family.
Having a working payment pipe is the most stressful part of the journey, but it's also the moment your hobby officially becomes a business.
I finally got the Stripe and RevenueCat webhooks fully certified and bulletproof, and EpicNotes officially launched this week.
If you want to see the result of those 36 hours of trench warfare, you can check out the app here: EpicNotes.
I'd love to hear from other solo devs: What's the most painful infrastructure you've had to wire yourself? Payment processing? Auth? Let me know in the comments!
Top comments (7)
the zombie user one is nasty. since you've got stripe on mac and revenuecat on android, worth giving each provider its own row and computing is_pro from both. with one shared flag, a cancel on one platform can switch off someone who's still paying on the other, and a late update from one can turn it back on after a real cancel.
You've named the exact reason the single flag was wrong, more clearly than I did in the article. I took the shortcut of one is_pro + expiry (restored from user_metadata), and it bakes in both failure modes you describe: a cancel on one platform switching off someone still paying on the other, and a late update from one provider resurrecting a real cancel.
The correct model is exactly yours β entitlements as rows, not flags:
entitlements(user_id, provider [stripe|revenue_cat], provider_subscription_id, status, expires_at, updated_at), unique on (user_id, provider), with is_pro computed as ANY active row. Cancel on Android while Stripe is still paying β still Pro, correctly. A late update can only ever touch its own row.
Bonus: it gives me a per-provider audit trail and a clean seam for a third provider later (Paddle, or Mac App Store IAP).
One edge case I'm still deciding: grace/retry windows β Stripe past_due, RevenueCat billing issues. Keep Pro alive during the provider's retry window, or cut immediately? Leaning "alive during retry, then cut," but that's a product call, not a technical one.
Ticket #2 on the backlog. Comments like this are the entire reason to write in public β thank you.
alive during retry, then cut is what i'd ship too. the rows make it easy since each one carries its own window: stripe stays past_due until the sub goes canceled or unpaid, and revenuecat has its own billing issue grace period, so is_pro just follows whichever provider is still trying. when the nightly reconcile lands, have it set the grace end too, so a missed payment_failed doesn't skip the window entirely.
Yes β exactly this. The rows carrying their own windows is what makes the policy
local instead of global: Stripe row lives while past_due, dies at canceled/unpaid;
RevenueCat row follows its own grace_period_expires_date. is_pro stops being a
decision and becomes a read: does any row still think it's paying, or still trying?
And that reconcile refinement is the piece I was missing. If the nightly pass only
corrects status, a missed payment_failed either leaves the row Pro forever or cuts
it with no window at all. Having the reconcile also write grace_expires_at from
provider state (Stripe from the invoice retry schedule / next_payment_attempt,
RevenueCat from grace_period_expires_date) means the window exists even when the
event that should have started it never arrives. Missed events degrade to "grace
runs on schedule," not "grace never happens."
Updating the ticket as we speak: reconcile owns grace_expires_at, rows own their
windows, is_pro is just EXISTS(active OR in-grace). This thread is turning into
the best kind of free design review β thank you.
The re-fetch fixes the ordering problem, but the sneaky case is a deleted event you never receive, because nothing re-triggers the check. Do you run a periodic reconcile against Stripe for the is_pro flag, or only react to webhooks?
Great catch β this is exactly the case my re-fetch doesn't cover. Re-fetching only helps if some event arrives to trigger it. A permanently lost deletion event (Stripe retries for ~3 days, then gives up) leaves silent drift forever.
Honest answer: today I'm webhook-reactive only. No reconciliation. Which means an endpoint outage longer than Stripe's retry window = a zombie Pro user by omission, not by retry.
What I'm adding (now ticket #1 on the backlog):
Scheduled reconciliation β a Supabase Edge Function on cron (nightly to start) that pages through GET /v1/subscriptions?status=all for every customer I hold, diffs status + current_period_end against local entitlements, corrects and logs every drift. Same loop against RevenueCat's GET /v1/subscribers/{id}.
Events-API gap polling between reconciliations β store the last processed event timestamp, poll GET /v1/events?created[gte]=..., so a missed webhook surfaces in minutes instead of at the nightly reconcile.
Alert on every correction β silent drift should at least be loud in the logs.
This comment alone made the article worth writing. Thank you.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.