Last week I discovered that people were paying for my product and getting nothing in return.
Not "nothing" as in the payment failed. The payment went through. Money hit my account. But the user who paid got no access, no account, no login — just a success page and a wall of confusion.
This is the story of a silent revenue killer hiding in a 200-line webhook handler.
The Symptom Was Invisible
There were no error logs. No failed transactions. No angry support emails (yet). The problem was structural:
A user subscribes → Paddle processes payment → Webhook fires → userId is null → Account never created.
The money was real. The access was not.
The Root Cause: Three Assumptions, All Wrong
My original flow made three assumptions that seemed perfectly reasonable at 2 AM:
Users exist before they pay. I assumed someone would create an account, log in, then subscribe. But my checkout page let anyone enter an email and pay — no account required.
The webhook will always find the user. My
checkout/route.tsdidn't pass the user's Supabase ID to Paddle'scustomData. So when the webhook fired, there was no way to link the payment back to a user.syncUserPlan(null)is a no-op. It wasn't. It silently did nothing — no error, no warning, no retry queue.
// The bug in one line:
const userId = await findUserByPaddleId(subscription.userId);
// userId = null → syncUserPlan(null) → nothing happens → money collected, access denied
The Fix: Four Changes That Closed the Loop
1. Create Auth Before Payment
// checkout/page.tsx
// Before: redirect straight to Paddle
// After: create Supabase user first, then pay
const { data: user } = await supabase.auth.signUp({
email,
password: crypto.randomUUID(), // temporary password
});
// Pass supabase_user_id to Paddle's customData
2. Reverse-Lookup the User in the Webhook
// webhooks/paddle/route.ts
async function findUserIdBySupabaseId(supabaseId: string) {
const { data } = await supabase
.from('User')
.select('id')
.eq('supabaseId', supabaseId)
.single();
return data?.id;
}
3. Magic Link on the Success Page
After payment, users see their email and a "Send Login Link" button. No passwords to remember, no support tickets.
// checkout/success/page.tsx
// Shows: "Check your inbox at user@email.com"
// Button: "Send me a login link" → Supabase magic link
4. Short-Circuit the Conversion Funnel
Old path: Article → Paywall → /pricing → Subscribe → /checkout → Email → Paddle → no account
New path: Article → Paywall → /checkout → Auth created → Paddle → Login link sent
One fewer step. One fewer place for users to drop off.
The Real Lesson: Test the Happy Path Like It's the Edge Case
We obsess over error handling for things that might fail. But the biggest risk is often the assumption that everything that should work actually does.
Questions I now ask myself before any payment flow goes live:
- What happens if the webhook fires before the user exists?
- What happens if the user exists but has no email?
- What happens if the payment succeeds but the database write fails?
- Can a user lose money without getting the thing they paid for?
If any of those answers are "I'm not sure" or "that can't happen," you probably have a silent revenue killer in production.
The Numbers That Matter
Before this fix:
- Payments processed: some number > 0
- Users actually getting access: fewer than they should have
- Support tickets: zero (because nobody knew to complain)
After this fix:
- Every payment creates a user
- Every user gets a login link
- Every login attempt is tracked
The scariest bugs aren't the ones that crash your app. They're the ones that quietly take your customers' money and give them nothing back.
Have you found a silent failure in your payment flow? I'd love to hear how you caught it — or if you're still looking.
Top comments (0)