billing webhook race condition solution guide
Billing Webhook Race Condition Solution Guide
Table of Contents
- Problem Statement
- Solution Overview
- Architecture Flow
- Implementation Steps
- Code Reference
- Testing
- Troubleshooting
Problem Statement
The Issue
When a user completes Stripe Checkout and is redirected back to the billing page, there's a race condition:
- User completes payment → Stripe redirects to
/settings/billing?checkout=success - Frontend loads billing page → Calls
/api/billing/status→ Returns stale data (no subscription yet) - Stripe webhook arrives (asynchronously, seconds later) → Updates subscription in database
- 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:
- Immediate Stripe Sync: When user returns from checkout, we immediately fetch subscription status directly from Stripe API and update the database synchronously
- 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:
- Import the new methods from composable:
const { billingStatus, isLoading, fetchStatus, createCheckout, changePlan, openPortal, syncCheckout, waitForSubscription } = useBilling()
- Add syncing state:
const isSyncing = ref(false)
- Update
onMountedhook 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:
- 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>
- 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
Backend:
apps/server/app/Http/Controllers/BillingController.phpapps/server/routes/api.php
Frontend:
apps/client/app/composables/useBilling.tsapps/client/app/pages/settings/billing.vue
Key Methods
Backend
BillingController::syncCheckout()- Syncs subscription from Stripe checkout sessionBillingController::updateTeamOfferFromSubscription()- Updates team offer/capabilitiesBillingController::getOfferFromPriceId()- Maps Stripe price ID to offer config
Frontend
useBilling::syncCheckout()- Calls sync endpointuseBilling::waitForSubscription()- Polls status endpoint with exponential backoffbilling.vue::onMounted()- Handles checkout redirect and triggers sync
Testing
Manual Testing Steps
Test Successful Checkout Flow:
- Start a new subscription checkout
- Complete payment in Stripe
- Verify redirect includes
session_idparameter - Verify loading overlay appears
- Verify subscription appears immediately after sync
- Verify success toast appears
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
Test Canceled Checkout:
- Start checkout
- Cancel in Stripe
- Verify redirect includes
checkout=canceled - Verify cancel toast appears
- Verify query params are cleaned up
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:
- Webhook failed to process
- Team doesn't have
stripe_idset (webhook can't find team) - Subscription status is not 'active' or 'trialing'
Solution:
- Check Stripe webhook logs
- Verify team has
stripe_idafter 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 setscustomer_emailwhenstripe_idis empty - Don't explicitly set
customer- let Cashier handle it automatically whenstripe_idexists
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:
- Synchronously fetching subscription status from Stripe when user returns
- Updating the database immediately (before webhook arrives)
- Polling as fallback if sync doesn't immediately show subscription
- 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