Most tutorials are bullshit.
Most tutorials show you the happy path: payment goes through, webhook hits your API, database updates, done. Sounds great until you realize your users are sitting there wondering if their $99/month payment actually worked.
I've debugged this exact scenario way too many times: "I paid but my dashboard still shows Free Plan!" Meanwhile, the payment went through 5 minutes ago, but the UI never updated.
The problem? Those tutorials skip the part where your UI actually updates without forcing users to refresh the page like it's 2015.
The Bullshit User Experience Most Apps Have
Here's what actually happens with basic webhook integration:
- User pays $29/month for your SaaS
- Stripe processes payment (user gets charged immediately)
- Webhook fires and updates your database 3-8 seconds later
- User lands back on your dashboard seeing "Free Plan"
- User panics and emails support: "I was charged but nothing changed!"
- You debug for 20 minutes just to tell them "try refreshing the page"
This broken flow exists in most SaaS apps I've tried. The fix is Supabase Real-time but most tutorials treat it like optional nice-to-have instead of core UX.
How Supabase Real-time Actually Works (And Why It Randomly Fails)
Here's how Supabase Real-time actually works under the hood - it's built on Phoenix's real-time stuff, which means it's pretty solid but has its quirks.
Basically, when your database row changes, Real-time catches it and pushes the update to connected clients. Works great... until it doesn't.
What you actually get with Real-time:
- Database Changes: Listens to INSERT, UPDATE, DELETE - but only if your RLS policies don't block it
- Presence: Track online users (works well when connections don't randomly drop)
- Broadcast: Send messages to all subscribers - useful for "your subscription expires in 5 minutes" warnings
- Channel filtering: Target specific users - absolutely critical or everyone sees everyone else's data
Real-time Subscription Flow That Actually Works
Here's how the integration should work with real-time capabilities:
1. User initiates payment → Stripe Checkout
2. Payment succeeds → Stripe webhook fires immediately
3. Webhook updates Supabase database → Real-time detects change
4. Real-time pushes update to all user's connected clients
5. UI updates instantly without page refresh
Critical Implementation Details (AKA Things That Will Break):
Your webhook handler needs the service role key to bypass RLS policies. I learned this at 11pm on a Friday when payments were going through but subscription statuses weren't updating.
The fun part? No error messages anywhere. Stripe webhooks: 200 OK. My API logs: everything looks fine. Users: "I paid but nothing changed!"
Took me 2 hours of digging through Supabase logs to find: new row violates row-level security policy for table \"profiles\"
(exact error from logs). The webhook was hitting my API, my API was calling Supabase, but RLS was silently blocking the updates.
Fixed it with this RLS policy:
CREATE POLICY \"Service role can update any profile\" ON public.profiles
FOR UPDATE USING (auth.role() = 'service_role');
Here's the RLS troubleshooting guide that saved my ass when this happened.
Other fun RLS failures: service role updates work but users can't read their own data, or updates work in dev but fail in prod because you forgot the service role policy.
Client-side real-time subscriptions should use the anon key and respect RLS policies. Don't fuck this up or users will see each other's subscription data (yes, I've seen this in production).
Database Schema for Real-time Subscriptions
Your database schema needs to support both webhook updates and real-time notifications without the usual auth fuckery. Here's the essential table structure:
-- Enhanced profiles table with real-time subscription tracking
CREATE TABLE public.profiles (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT,
stripe_customer_id TEXT UNIQUE,
-- Subscription state fields for real-time sync
subscription_id TEXT,
subscription_status TEXT DEFAULT 'inactive',
subscription_tier TEXT,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
-- Real-time sync metadata
last_webhook_event TEXT, -- Track latest processed webhook ID
sync_status TEXT DEFAULT 'synced', -- 'synced', 'pending', 'error'
created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()),
updated_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()),
PRIMARY KEY (id)
);
-- Enable RLS and real-time
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER PUBLICATION supabase_realtime ADD TABLE public.profiles;
-- RLS policies for secure real-time access
CREATE POLICY \"Users can view own profile\" ON public.profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY \"Service role can update profiles\" ON public.profiles
FOR UPDATE USING (auth.role() = 'service_role');
RLS Policy Considerations:
The auth.role() = 'service_role'
policy ensures that only webhook handlers using the service role key can update subscription data, preventing unauthorized client-side modifications while still allowing real-time updates to flow to authenticated users.
Next.js Real-time Client Setup
Setting up the client-side real-time subscription requires careful handling of authentication state and connection lifecycle:
// hooks/useRealtimeSubscription.ts
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { User } from '@supabase/supabase-js'
interface SubscriptionState {
subscription_status: string
subscription_tier: string | null
current_period_end: string | null
sync_status: 'synced' | 'pending' | 'error'
}
export function useRealtimeSubscription(user: User | null) {
const [subscriptionState, setSubscriptionState] = useState<SubscriptionState | null>(null)
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
if (!user) {
setSubscriptionState(null)
setIsConnected(false)
return
}
const supabase = createClient()
// Initial subscription state fetch
const fetchInitialState = async () => {
const { data } = await supabase
.from('profiles')
.select('subscription_status, subscription_tier, current_period_end, sync_status')
.eq('id', user.id)
.single()
if (data) setSubscriptionState(data)
}
fetchInitialState()
// Set up real-time subscription
const channel = supabase
.channel(`profile-changes-${user.id}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'profiles',
filter: `id=eq.${user.id}`,
},
(payload) => {
// Finally! A webhook that actually worked
console.log('Real-time subscription update:', payload.new)
setSubscriptionState(payload.new as SubscriptionState)
}
)
.subscribe((status) => {
setIsConnected(status === 'SUBSCRIBED')
})
// Cleanup on unmount or user change
return () => {
supabase.removeChannel(channel)
setIsConnected(false)
}
}, [user?.id]) // Re-subscribe when user changes
return { subscriptionState, isConnected }
}
Enhanced Webhook Handler with Real-time Events
The webhook handler needs modification to work seamlessly with real-time subscriptions:
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-04-10' // Latest stable version - always check Stripe docs
})
export async function POST(req: NextRequest) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
// This fails for the stupidest reasons - wrong secret, URL encoding issues, etc.
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// Use service role client for database updates
const supabase = createClient()
try {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionUpdate(supabase, event)
break
case 'customer.subscription.deleted':
await handleSubscriptionCancellation(supabase, event)
break
case 'invoice.payment_succeeded':
await handleSuccessfulPayment(supabase, event)
break
case 'invoice.payment_failed':
await handleFailedPayment(supabase, event)
break
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('Webhook processing error:', error)
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
)
}
}
async function handleSubscriptionUpdate(supabase: any, event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription
// Enhanced update with real-time friendly data structure
const { error } = await supabase
.from('profiles')
.update({
subscription_id: subscription.id,
subscription_status: subscription.status,
subscription_tier: subscription.items.data[0]?.price?.lookup_key || null,
current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
last_webhook_event: event.id,
sync_status: 'synced',
updated_at: new Date().toISOString(),
})
.eq('stripe_customer_id', subscription.customer as string)
if (error) {
console.error('Database update error:', error)
throw error
}
// Real-time update will be automatically triggered by the database change
console.log(`Subscription ${subscription.id} updated for customer ${subscription.customer}`)
}
Connection State Management
Real-time connections can drop due to network issues or server restarts. Implement connection state management to handle these scenarios gracefully:
// components/SubscriptionStatusBadge.tsx
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'
import { useUser } from '@/hooks/useUser' // Your auth hook
export function SubscriptionStatusBadge() {
const { user } = useUser()
const { subscriptionState, isConnected } = useRealtimeSubscription(user)
if (!subscriptionState) {
return <div className=\"animate-pulse bg-gray-200 rounded px-2 py-1\">Loading...</div>
}
return (
<div className=\"flex items-center space-x-2\">
<span className={`px-3 py-1 rounded text-sm font-medium ${
subscriptionState.subscription_status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{subscriptionState.subscription_status}
</span>
{/* Real-time connection indicator */}
<div className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-green-500' : 'bg-red-500'
}`} title={isConnected ? 'Real-time connected' : 'Real-time disconnected'} />
{subscriptionState.sync_status === 'pending' && (
<span className=\"text-xs text-yellow-600\">Syncing...</span>
)}
</div>
)
}
Performance and Scaling Considerations
Real-time subscriptions consume WebSocket connections and server resources. Here are key considerations for production deployment:
Connection Management:
- Each browser tab creates a separate WebSocket connection
- Supabase has connection limits per project (500 concurrent connections on Pro tier - sounds like a lot until you have actual users)
- Implement connection pooling for high-traffic applications
Real-time Policy Optimization:
- Use specific table filters (
filter: 'id=eq.${user.id}'
) to reduce unnecessary data transfer - Avoid subscribing to entire tables - target specific user data
- Consider using Supabase Edge Functions for complex real-time logic
Webhook Reliability:
- Implement idempotency handling for duplicate webhook events
- Add retry logic for failed database updates
- Monitor webhook processing latency - target under 30 seconds to avoid Stripe retries
Get this working and support stops asking why payments "don't work." Fuck it up and you'll explain the same shit every weekend.
The goal isn't perfect real-time sync (impossible). It's stopping users from panicking when their credit card gets charged but their dashboard still shows "Free Plan."
Next up: the actual implementation, including all the ways this breaks in production and how to fix it without losing your mind.