StripePaymentsBackendTypeScript

Stripe Integration Done Right: Avoiding the Split-Brain Problem

March 15, 202512 min read

Stripe Integration Done Right: Avoiding the Split-Brain Problem

After integrating Stripe into numerous production applications, I've seen the same architectural mistakes repeated over and over. The root cause? What I call the "split-brain problem" - trying to maintain synchronized state between Stripe's systems and your database through event-driven webhooks.

The Problem with Traditional Webhook-Based Approaches

Stripe has over 258 event types with varying data structures and no guaranteed ordering. When you try to update your database based on individual webhook events, you're essentially building a distributed state machine without proper coordination.

Consider this common (but flawed) pattern:

typescript
// DON'T DO THIS - Event-based state updates app.post('/webhook', async (req, res) => { const event = stripe.webhooks.constructEvent(/*...*/); switch (event.type) { case 'customer.subscription.created': await db.subscriptions.create({ /* partial data */ }); break; case 'customer.subscription.updated': await db.subscriptions.update({ /* partial data */ }); break; case 'invoice.paid': await db.subscriptions.updateStatus('active'); break; // ... 20+ more cases } });

Why this fails:

  • Events can arrive out of order
  • Network issues can cause duplicate deliveries
  • Your logic must handle every edge case for every event type
  • State can diverge between Stripe and your database

The Solution: Single Source of Truth Sync Function

Instead of tracking partial updates through webhooks, implement a single sync function that fetches the complete customer state from Stripe and stores it in a key-value store:

typescript
import Stripe from 'stripe'; import { Redis } from '@upstash/redis'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const redis = Redis.fromEnv(); interface CachedSubscription { subscriptionId: string; status: Stripe.Subscription.Status; priceId: string; currentPeriodStart: number; currentPeriodEnd: number; cancelAtPeriodEnd: boolean; paymentMethod: { brand: string; last4: string; } | null; } type SubscriptionData = CachedSubscription | { status: 'none' }; export async function syncStripeDataToKV( customerId: string ): Promise<SubscriptionData> { // Fetch complete subscription state from Stripe const subscriptions = await stripe.subscriptions.list({ customer: customerId, status: 'all', expand: ['data.default_payment_method'], limit: 1, }); const subscription = subscriptions.data[0]; if (!subscription) { const data: SubscriptionData = { status: 'none' }; await redis.set(`subscription:${customerId}`, JSON.stringify(data)); return data; } const paymentMethod = subscription.default_payment_method as Stripe.PaymentMethod | null; const data: CachedSubscription = { subscriptionId: subscription.id, status: subscription.status, priceId: subscription.items.data[0]?.price.id ?? '', currentPeriodStart: subscription.current_period_start, currentPeriodEnd: subscription.current_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end, paymentMethod: paymentMethod?.card ? { brand: paymentMethod.card.brand, last4: paymentMethod.card.last4, } : null, }; await redis.set(`subscription:${customerId}`, JSON.stringify(data)); return data; }

The Complete Implementation Flow

1. Checkout Endpoint

Critical: Always create a Stripe customer before initiating checkout. This prevents orphaned checkout sessions and ensures you always have a customer ID to track.

typescript
import { auth } from '@/lib/auth'; export async function POST(req: Request) { const session = await auth(); if (!session?.user?.id) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } const { priceId } = await req.json(); // Get or create Stripe customer let customerId = await redis.get(`user_customer:${session.user.id}`); if (!customerId) { const customer = await stripe.customers.create({ email: session.user.email!, metadata: { userId: session.user.id }, }); customerId = customer.id; // Store the mapping both ways await redis.set(`user_customer:${session.user.id}`, customerId); await redis.set(`customer_user:${customerId}`, session.user.id); } const checkoutSession = await stripe.checkout.sessions.create({ customer: customerId as string, mode: 'subscription', payment_method_types: ['card'], line_items: [{ price: priceId, quantity: 1 }], success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`, }); return Response.json({ url: checkoutSession.url }); }

2. Success Page Handler

After successful payment, immediately sync the subscription data:

typescript
// app/success/page.tsx import { syncStripeDataToKV } from '@/lib/stripe-sync'; export default async function SuccessPage({ searchParams, }: { searchParams: { session_id?: string }; }) { if (!searchParams.session_id) { redirect('/'); } const checkoutSession = await stripe.checkout.sessions.retrieve( searchParams.session_id ); if (checkoutSession.customer) { await syncStripeDataToKV(checkoutSession.customer as string); } return ( <div> <h1>Payment Successful!</h1> <p>Your subscription is now active.</p> </div> ); }

3. Simplified Webhook Handler

The webhook handler becomes dramatically simpler - it just triggers the same sync function:

typescript
import { NextRequest } from 'next/server'; import { headers } from 'next/headers'; const allowedEvents = new Set([ 'checkout.session.completed', 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', 'customer.subscription.paused', 'customer.subscription.resumed', 'customer.subscription.pending_update_applied', 'customer.subscription.pending_update_expired', 'customer.subscription.trial_will_end', 'invoice.paid', 'invoice.payment_failed', 'invoice.payment_action_required', 'invoice.upcoming', 'invoice.marked_uncollectible', 'payment_intent.succeeded', 'payment_intent.payment_failed', ]); export async function POST(req: NextRequest) { const body = await req.text(); const headersList = await headers(); const signature = headersList.get('stripe-signature')!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error('Webhook signature verification failed'); return Response.json({ error: 'Invalid signature' }, { status: 400 }); } // Return immediately, process asynchronously const response = Response.json({ received: true }); // Use waitUntil for async processing (Vercel/Cloudflare) if (allowedEvents.has(event.type)) { const customerId = extractCustomerId(event); if (customerId) { // Process async - don't block the response waitUntil(syncStripeDataToKV(customerId)); } } return response; } function extractCustomerId(event: Stripe.Event): string | null { const obj = event.data.object as any; if (obj.customer) { return typeof obj.customer === 'string' ? obj.customer : obj.customer.id; } return null; }

Reading Subscription Data

When your application needs to check subscription status, read from the KV store:

typescript
export async function getSubscription( userId: string ): Promise<SubscriptionData | null> { const customerId = await redis.get(`user_customer:${userId}`); if (!customerId) { return null; } const cached = await redis.get(`subscription:${customerId}`); if (!cached) { // Fallback: sync from Stripe if not cached return syncStripeDataToKV(customerId as string); } return JSON.parse(cached as string); } // Usage in your app const subscription = await getSubscription(user.id); if (subscription?.status === 'active') { // Grant access to premium features }

Additional Recommendations

Disable Cash App Pay

I've observed that approximately 90% of fraudulent transactions and chargebacks in my applications came through Cash App Pay. Unless you have a specific reason to support it, disable it in your Stripe Dashboard.

Limit to One Subscription Per Customer

Enable the "Limit customers to one subscription" setting in Stripe to prevent race conditions from simultaneous checkout sessions:

Dashboard > Settings > Subscriptions > Customer can have at most one subscription

This prevents edge cases where a user opens multiple tabs and creates duplicate subscriptions.

Handle Edge Cases Gracefully

typescript
export async function ensureSubscriptionSync(userId: string) { const customerId = await redis.get(`user_customer:${userId}`); if (!customerId) { return { status: 'none' as const }; } try { return await syncStripeDataToKV(customerId as string); } catch (error) { console.error('Failed to sync subscription:', error); // Return cached data if available const cached = await redis.get(`subscription:${customerId}`); if (cached) { return JSON.parse(cached as string); } return { status: 'none' as const }; } }

Key Takeaways

  1. Don't build a distributed state machine - Use a single sync function as the source of truth
  2. Always create customers before checkout - Never rely on checkout sessions to create customers
  3. Store bidirectional mappings - userId <-> customerId for easy lookups
  4. Use KV storage - Redis/Upstash provides fast reads for subscription checks
  5. Keep webhooks simple - Just trigger the sync function, don't handle individual events
  6. Return webhook responses immediately - Process asynchronously with waitUntil

This architecture has proven robust across multiple production applications handling thousands of subscriptions. The key insight is that Stripe is the source of truth - your job is to mirror that state, not reconstruct it from events.


Have questions about implementing Stripe in your application? Feel free to reach out - I've handled payment integrations at scale and love discussing the nuances of billing systems.

SS

Shreyansh Sheth

Full Stack Developer & AI Engineer with 7+ years of experience building scalable SaaS products and AI-powered solutions.

View Portfolio