← back home

billing webhook race condition solution guide

Billing Webhook Race Condition Solution Guide

Table of Contents

  1. Problem Statement
  2. Solution Overview
  3. Architecture Flow
  4. Implementation Steps
  5. Code Reference
  6. Testing
  7. Troubleshooting

Problem Statement

The Issue

When a user completes Stripe Checkout and is redirected back to the billing page, there's a race condition:

  1. User completes payment → Stripe redirects to /settings/billing?checkout=success
  2. Frontend loads billing page → Calls /api/billing/status → Returns stale data (no subscription yet)
  3. Stripe webhook arrives (asynchronously, seconds later) → Updates subscription in database
  4. User sees outdated information until they manually refresh

Why This Happens

  • Stripe webhooks are asynchronous and may take 1-5 seconds to arrive
  • The frontend loads immediately after redirect
  • Database hasn't been updated yet when the status endpoint is called

Solution Overview

We implement a two-pronged approach:

  1. Immediate Stripe Sync: When user returns from checkout, we immediately fetch subscription status directly from Stripe API and update the database synchronously
  2. Polling Fallback: If the sync doesn't immediately show the subscription (edge case), we poll the status endpoint with exponential backoff

Benefits

  • ✅ User sees updated data immediately (no waiting for webhook)
  • ✅ Works even if webhook is delayed or fails
  • ✅ Graceful fallback with polling
  • ✅ Better user experience with loading states

Architecture Flow

┌─────────────┐
│   User      │
│  Completes  │
│  Checkout   │
└──────┬──────┘
       │
       ▼
┌─────────────────────────────────────┐
│  Stripe Redirects to:                │
│  /settings/billing?checkout=success  │
│  &session_id={CHECKOUT_SESSION_ID}   │
└──────┬───────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────┐
│  Frontend: billing.vue               │
│  - Detects checkout=success          │
│  - Extracts session_id               │
│  - Shows loading overlay             │
└──────┬───────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────┐
│  POST /api/billing/sync-checkout    │
│  Body: { session_id: "cs_..." }     │
└──────┬───────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────┐
│  Backend: BillingController         │
│  - Retrieves session from Stripe    │
│  - Extracts subscription data       │
│  - Updates team offer/capabilities  │
│  - Returns updated billing status   │
└──────┬───────────────────────────────┘
       │
       ▼
┌─────────────────────────────────────┐
│  Frontend: Checks if subscription │
│  is present in response             │
└──────┬───────────────────────────────┘
       │
       ├─── YES ──► Show success toast
       │
       └─── NO ──► Poll /api/billing/status
                   (1s, 2s, 3s, 4s, 5s intervals)
                   Max 5 attempts

Parallel Process (doesn't block user):

Stripe Webhook → POST /stripe/webhook
                → StripeEventListener
                → Updates team (idempotent, safe to run after sync)

Implementation Steps

Step 1: Update Checkout Success URL

File: apps/server/app/Http/Controllers/BillingController.php

Location: checkout() method

Change: Add {CHECKOUT_SESSION_ID} placeholder to success URL

$checkoutOptions = [
    'success_url' => config('app.frontend_url').'/settings/billing?checkout=success&session_id={CHECKOUT_SESSION_ID}',
    'cancel_url' => config('app.frontend_url').'/settings/billing?checkout=canceled',
    'mode' => 'subscription',
];

Why: Stripe will replace {CHECKOUT_SESSION_ID} with the actual session ID when redirecting.


Step 2: Create Sync Checkout Endpoint (Backend)

File: apps/server/app/Http/Controllers/BillingController.php

Add new method: syncCheckout()

/**
 * Sync subscription status from Stripe checkout session.
 */
public function syncCheckout(Request $request): JsonResponse
{
    $request->validate([
        'session_id' => ['required', 'string'],
    ]);

    /** @var \App\Models\User $user */
    $user = $request->user();
    $team = $user->currentTeam;

    if (! $team) {
        return response()->json([
            'message' => 'No team selected',
        ], 400);
    }

    try {
        // Retrieve checkout session with expanded subscription
        $session = StripeSession::retrieve(
            $request->input('session_id'),
            ['expand' => ['subscription']]
        );

        if (! $session->subscription) {
            return response()->json([
                'message' => 'No subscription found in checkout session',
            ], 400);
        }

        // Get subscription data
        $subscription = $session->subscription;
        if (is_string($subscription)) {
            $subscription = StripeSubscription::retrieve($subscription, ['expand' => ['items.data.price']]);
        }

        // Convert subscription to array format similar to webhook payload
        $subscriptionData = [
            'status' => $subscription->status,
            'customer' => $subscription->customer,
            'items' => [
                'data' => [
                    [
                        'price' => [
                            'id' => $subscription->items->data[0]->price->id ?? null,
                        ],
                    ],
                ],
            ],
        ];

        // Update team offer and capabilities
        $this->updateTeamOfferFromSubscription($team, $subscriptionData);

        // Refresh team to get updated data
        $team->refresh();

        // Return updated billing status
        return $this->status($request);
    } catch (ApiErrorException $e) {
        return response()->json([
            'message' => 'Failed to sync checkout session',
            'error' => $e->getMessage(),
        ], 500);
    }
}

Add helper method: updateTeamOfferFromSubscription()

This method should match the logic in StripeEventListener to ensure consistency:

/**
 * Update team offer and capabilities from subscription.
 */
protected function updateTeamOfferFromSubscription(\App\Models\Team $team, array $subscription): void
{
    $status = $subscription['status'] ?? null;

    // Apply capabilities for active or trialing subscriptions
    if (in_array($status, ['active', 'trialing'])) {
        $priceId = $subscription['items']['data'][0]['price']['id'] ?? null;
        if (! $priceId) {
            return;
        }

        // Determine offer and frequency from price ID
        $offerConfig = $this->getOfferFromPriceId($priceId);
        if (! $offerConfig) {
            return;
        }

        $team->offer = $offerConfig['offer'];
        $team->capabilities = $offerConfig['capabilities'];
        $team->save();

        return;
    }

    // For canceled, unpaid, past_due, etc. - set capabilities to null
    $team->capabilities = null;
    $team->offer = null;
    $team->save();
}

/**
 * Get offer configuration from Stripe price ID.
 */
protected function getOfferFromPriceId(string $priceId): ?array
{
    $offers = config('offers');
    $prices = config('services.stripe.prices');

    // Check Solo prices
    if ($priceId === $prices['solo_monthly'] || $priceId === $prices['solo_yearly']) {
        $config = $offers[OfferFrequency::MONTHLY->value][Offer::SOLO->value] ?? null;
        if ($config) {
            return [
                'offer' => Offer::SOLO->value,
                'capabilities' => $config['capabilities'],
            ];
        }
    }

    // Check Pro prices
    if ($priceId === $prices['pro_monthly'] || $priceId === $prices['pro_yearly']) {
        $config = $offers[OfferFrequency::MONTHLY->value][Offer::PRO->value] ?? null;
        if ($config) {
            return [
                'offer' => Offer::PRO->value,
                'capabilities' => $config['capabilities'],
            ];
        }
    }

    // Check Ultime prices
    if ($priceId === $prices['ultime_monthly'] || $priceId === $prices['ultime_yearly']) {
        $config = $offers[OfferFrequency::MONTHLY->value][Offer::ULTIME->value] ?? null;
        if ($config) {
            return [
                'offer' => Offer::ULTIME->value,
                'capabilities' => $config['capabilities'],
            ];
        }
    }

    return null;
}

Add imports at the top of the file:

use Stripe\Checkout\Session as StripeSession;
use Stripe\Exception\ApiErrorException;
use Stripe\Subscription as StripeSubscription;

Step 3: Register API Route

File: apps/server/routes/api.php

Add route inside the auth:sanctum middleware group:

Route::post('/billing/sync-checkout', [BillingController::class, 'syncCheckout']);

Full route group should look like:

Route::get('/billing/status', [BillingController::class, 'status']);
Route::post('/billing/checkout', [BillingController::class, 'checkout']);
Route::post('/billing/sync-checkout', [BillingController::class, 'syncCheckout']);
Route::post('/billing/change', [BillingController::class, 'change']);
Route::get('/billing/portal', [BillingController::class, 'portal']);

Step 4: Update Frontend Composable

File: apps/client/app/composables/useBilling.ts

Add two new methods to the _useBilling function:

const syncCheckout = async (sessionId: string) => {
  isLoading.value = true
  try {
    const data = await client<BillingStatus>('/api/billing/sync-checkout', {
      method: 'POST',
      body: { session_id: sessionId }
    })
    billingStatus.value = data
    return data
  } catch (error) {
    console.error('Failed to sync checkout:', error)
    throw error
  } finally {
    isLoading.value = false
  }
}

const waitForSubscription = async (maxAttempts: number = 5): Promise<BillingStatus | null> => {
  const delays = [1000, 2000, 3000, 4000, 5000] // 1s, 2s, 3s, 4s, 5s
  
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    await fetchStatus()
    
    // Check if subscription is now present
    if (billingStatus.value?.subscription) {
      return billingStatus.value
    }
    
    // Wait before next attempt (except on last attempt)
    if (attempt < maxAttempts - 1) {
      await new Promise(resolve => setTimeout(resolve, delays[attempt]))
    }
  }
  
  return null
}

Update return statement to export the new methods:

return {
  billingStatus,
  isLoading,
  fetchStatus,
  createCheckout,
  changePlan,
  openPortal,
  syncCheckout,        // Add this
  waitForSubscription, // Add this
};

Step 5: Update Billing Page

File: apps/client/app/pages/settings/billing.vue

Update script section:

  1. Import the new methods from composable:
const { billingStatus, isLoading, fetchStatus, createCheckout, changePlan, openPortal, syncCheckout, waitForSubscription } = useBilling()
  1. Add syncing state:
const isSyncing = ref(false)
  1. Update onMounted hook to handle checkout redirect:
onMounted(async () => {
  const route = useRoute()
  const router = useRouter()
  
  // Check if we're returning from checkout
  const checkoutStatus = route.query.checkout
  const sessionId = route.query.session_id as string | undefined
  
  if (checkoutStatus === 'success' && sessionId) {
    isSyncing.value = true
    try {
      // Sync checkout session first
      await syncCheckout(sessionId)
      
      // Check if subscription is now present
      if (!billingStatus.value?.subscription) {
        // Poll for subscription if not present yet
        const result = await waitForSubscription()
        if (result) {
          toast.add({
            title: 'Subscription activated',
            description: 'Your subscription has been successfully activated',
            icon: 'i-lucide-check-circle',
            color: 'success'
          })
        } else {
          toast.add({
            title: 'Subscription pending',
            description: 'Your subscription is being processed. Please refresh in a moment.',
            icon: 'i-lucide-clock',
            color: 'warning'
          })
        }
      } else {
        toast.add({
          title: 'Subscription activated',
          description: 'Your subscription has been successfully activated',
          icon: 'i-lucide-check-circle',
          color: 'success'
        })
      }
      
      // Clean up query params
      router.replace({ query: {} })
    } catch (error: any) {
      toast.add({
        title: 'Failed to sync subscription',
        description: error?.data?.message || 'Please refresh the page',
        icon: 'i-lucide-alert-triangle',
        color: 'error'
      })
    } finally {
      isSyncing.value = false
    }
  } else if (checkoutStatus === 'canceled') {
    toast.add({
      title: 'Checkout canceled',
      description: 'Your checkout session was canceled',
      icon: 'i-lucide-x-circle',
      color: 'warning'
    })
    // Clean up query params
    router.replace({ query: {} })
  } else {
    // Normal page load
    await fetchStatus()
  }
})

Update template section:

  1. Add full-page overlay loader for syncing (before the main content):
<template>
  <div class="space-y-6 relative">
    <!-- Full-page overlay loader for syncing -->
    <div
      v-if="isSyncing"
      class="fixed inset-0 bg-black/50 dark:bg-black/70 z-50 flex items-center justify-center"
    >
      <UPageCard variant="subtle" class="max-w-sm w-full mx-4">
        <div class="flex flex-col items-center justify-center py-8">
          <UIcon
            name="i-lucide-loader-2"
            class="w-8 h-8 animate-spin text-primary-600 dark:text-primary-400 mb-4"
          />
          <h3 class="text-lg font-semibold mb-2">
            Syncing Subscription
          </h3>
          <p class="text-sm text-gray-500 dark:text-gray-400 text-center">
            Please wait while we update your subscription information...
          </p>
        </div>
      </UPageCard>
    </div>

    <!-- Rest of your template -->
    ...
  </div>
</template>
  1. Update loading card to only show for normal loading (not syncing):
<UPageCard v-if="isLoading" variant="subtle">
  <div class="flex items-center justify-center py-8">
    <div class="text-center">
      <UIcon name="i-lucide-loader-2" class="w-6 h-6 animate-spin mx-auto mb-2" />
      <p class="text-sm text-gray-500 dark:text-gray-400">
        Loading billing information...
      </p>
    </div>
  </div>
</UPageCard>

Code Reference

Key Files Modified

  1. Backend:

    • apps/server/app/Http/Controllers/BillingController.php
    • apps/server/routes/api.php
  2. Frontend:

    • apps/client/app/composables/useBilling.ts
    • apps/client/app/pages/settings/billing.vue

Key Methods

Backend

  • BillingController::syncCheckout() - Syncs subscription from Stripe checkout session
  • BillingController::updateTeamOfferFromSubscription() - Updates team offer/capabilities
  • BillingController::getOfferFromPriceId() - Maps Stripe price ID to offer config

Frontend

  • useBilling::syncCheckout() - Calls sync endpoint
  • useBilling::waitForSubscription() - Polls status endpoint with exponential backoff
  • billing.vue::onMounted() - Handles checkout redirect and triggers sync

Testing

Manual Testing Steps

  1. Test Successful Checkout Flow:

    • Start a new subscription checkout
    • Complete payment in Stripe
    • Verify redirect includes session_id parameter
    • Verify loading overlay appears
    • Verify subscription appears immediately after sync
    • Verify success toast appears
  2. Test Polling Fallback:

    • Simulate slow webhook (delay in Stripe dashboard)
    • Complete checkout
    • Verify sync happens first
    • If subscription not present, verify polling occurs
    • Verify final state shows subscription
  3. Test Canceled Checkout:

    • Start checkout
    • Cancel in Stripe
    • Verify redirect includes checkout=canceled
    • Verify cancel toast appears
    • Verify query params are cleaned up
  4. Test Error Handling:

    • Use invalid session_id
    • Verify error toast appears
    • Verify page still loads normally

Edge Cases to Test

  • Webhook arrives before sync: Both should work (idempotent operations)
  • Webhook arrives after sync: Webhook should update same data (no conflict)
  • Network failure during sync: Error should be handled gracefully
  • Stripe API timeout: Should show error message

Troubleshooting

Issue: Sync endpoint returns 400 "No subscription found"

Cause: Checkout session may not have completed, or subscription wasn't created yet.

Solution:

  • Verify the session_id is valid in Stripe dashboard
  • Check that payment was actually completed
  • Ensure checkout session mode is 'subscription'

Issue: Polling never finds subscription

Possible Causes:

  1. Webhook failed to process
  2. Team doesn't have stripe_id set (webhook can't find team)
  3. Subscription status is not 'active' or 'trialing'

Solution:

  • Check Stripe webhook logs
  • Verify team has stripe_id after checkout
  • Check subscription status in Stripe dashboard
  • Verify webhook endpoint is accessible

Issue: "You may only specify one of these parameters: customer, customer_email"

Cause: Both customer and customer_email are being sent to Stripe.

Solution:

  • Ensure checkout() method only sets customer_email when stripe_id is empty
  • Don't explicitly set customer - let Cashier handle it automatically when stripe_id exists

Issue: Race condition between sync and webhook

Current Behavior: Both operations are idempotent and produce the same result, so this is safe.

Future Improvement: Add idempotency checks to skip updates if data is already correct:

// Only update if values would change
if ($team->offer === $offerConfig['offer'] && 
    $team->capabilities === $offerConfig['capabilities']) {
    return; // Already up to date
}

Summary

This solution ensures users see updated subscription data immediately after checkout by:

  1. Synchronously fetching subscription status from Stripe when user returns
  2. Updating the database immediately (before webhook arrives)
  3. Polling as fallback if sync doesn't immediately show subscription
  4. Providing clear feedback with loading states and toast notifications

The webhook still processes in the background and will update the same data (idempotent), ensuring consistency even if sync fails.


Additional Notes

  • The sync endpoint reuses the same logic as the webhook handler for consistency
  • Both operations are safe to run multiple times (idempotent)
  • The polling uses exponential backoff to avoid overwhelming the server
  • The solution gracefully degrades if Stripe API is unavailable