payment-status-sync
$npx skills add blunotech-dev/agents --skill payment-status-syncHandle edge cases in payment and subscription state sync beyond basic webhooks, including out-of-order event delivery, retries, and status reconciliation. Ensure accurate UI display for payment states, grace periods, and next actions. Use when debugging webhook ordering issues, syncing Stripe status with your system, or building reliable billing status pages. Trigger on out-of-order webhooks, stale or incorrect status, payment retries, subscription state mismatch, or billing UI inconsistencies.
| name | description | category |
|---|---|---|
| payment-status-sync | Handle edge cases in payment and subscription state sync beyond basic webhooks, including out-of-order event delivery, retries, and status reconciliation. Ensure accurate UI display for payment states, grace periods, and next actions. Use when debugging webhook ordering issues, syncing Stripe status with your system, or building reliable billing status pages. Trigger on out-of-order webhooks, stale or incorrect status, payment retries, subscription state mismatch, or billing UI inconsistencies. | Fullstack |
Payment Status Sync
Covers what breaks after basic webhook handling is in place: events arriving out of order, status that looks stale to the user mid-flow, and building a status display that accurately reflects every sub-state without confusing users. Assumes idempotency and basic event handling already exist (see subscription-fullstack).
Discovery
Before writing anything, answer:
- Do you have a
webhookEventdeduplication table? If not, add one before this skill adds anything useful. - What's the user-visible status page? Account settings, a dedicated billing page, or a banner in the app header?
- Retry behavior: How many payment retry attempts does Stripe make before marking
unpaid? (Configurable in Stripe Dashboard — default is 4 over ~4 weeks.) - Dunning emails: Sent by Stripe Smart Retries, your own system, or both?
Core Patterns
1. Out-of-Order Webhook Delivery
Stripe does not guarantee event ordering. A common real sequence:
Expected: checkout.session.completed → invoice.payment_succeeded → customer.subscription.updated
Actual: customer.subscription.updated → checkout.session.completed → invoice.payment_succeeded
The bug: if customer.subscription.updated arrives first, your handler looks up stripeSubId in the DB to find the subscription — but checkout.session.completed hasn't run yet, so stripeSubId is null. The update silently no-ops or throws.
Fix: look up by stripeCustomerId, not stripeSubId
// FRAGILE — stripeSubId may not exist yet when this event arrives
await db.subscription.update({
where: { stripeSubId: sub.id },
data: { status: sub.status },
});
// ROBUST — stripeCustomerId is written at customer creation, always exists
await db.subscription.update({
where: { stripeCustomerId: sub.customer as string },
data: {
stripeSubId: sub.id, // write it here too, idempotently
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
cancelAtPeriodEnd: sub.cancel_at_period_end,
},
});
Apply this pattern to every webhook handler — always look up by stripeCustomerId.
2. Timestamp-Guarded Updates
Out-of-order events also mean a stale event can overwrite a newer one. An invoice.payment_failed arriving after invoice.payment_succeeded would incorrectly set status: 'past_due'.
Fix: only write if the incoming event is newer than what's stored
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
// event.created is unix seconds — the timestamp of when Stripe generated the event
await db.subscription.updateMany({
where: {
stripeCustomerId: sub.customer as string,
lastWebhookAt: { lt: new Date(event.created * 1000) }, // only if older
},
data: {
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
lastWebhookAt: new Date(event.created * 1000),
},
});
// Non-obvious: updateMany with a where clause — if the record was already
// updated by a newer event, this silently no-ops instead of overwriting.
break;
}
Add lastWebhookAt DateTime? to your subscription model. Initialize it on creation.
3. Reconciliation Job — Catch What Webhooks Miss
Webhooks fail silently when: your server is down during delivery, a handler throws after the 200 was already sent, or Stripe's retry window (72 hours) expires without a successful ack.
Fix: a periodic reconciliation job that syncs from Stripe directly
// Run nightly via cron or a queue job
async function reconcileSubscriptions() {
// Find subscriptions that haven't received a webhook in >24 hours
// and are in a non-terminal state
const stale = await db.subscription.findMany({
where: {
status: { in: ['active', 'trialing', 'past_due'] },
lastWebhookAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) },
stripeSubId: { not: null },
},
});
for (const sub of stale) {
try {
const stripeSub = await stripe.subscriptions.retrieve(sub.stripeSubId!);
await db.subscription.update({
where: { id: sub.id },
data: {
status: stripeSub.status,
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
lastWebhookAt: new Date(), // reset so it won't be flagged again immediately
},
});
} catch (err) {
// Log but continue — don't let one bad record abort the whole job
console.error('Reconcile failed for', sub.id, err);
}
}
}
Non-obvious: this job also catches the case where a user cancels their subscription directly in the Stripe Dashboard (bypassing your cancel endpoint). Without reconciliation, your DB stays active indefinitely.
4. Status Display — Every Sub-State the User Can See
Raw Stripe statuses map poorly to user-facing copy. Define the mapping once:
// lib/subscriptionDisplay.ts
interface StatusDisplay {
label: string;
description: string;
severity: 'success' | 'warning' | 'error' | 'neutral';
action?: { label: string; href: string };
}
export function getStatusDisplay(sub: Subscription): StatusDisplay {
const portalHref = '/api/billing/portal'; // redirects to Stripe Billing Portal
// Non-obvious: cancelAtPeriodEnd is not a Stripe status — it's a flag on an
// 'active' subscription. Check it before checking status or you'll never show it.
if (sub.status === 'active' && sub.cancelAtPeriodEnd) {
const date = sub.currentPeriodEnd.toLocaleDateString();
return {
label: 'Cancels ' + date,
description: `Your plan is active until ${date}. You won't be charged again.`,
severity: 'warning',
action: { label: 'Resume subscription', href: portalHref },
};
}
const map: Record<string, StatusDisplay> = {
trialing: {
label: 'Free trial',
description: `Trial ends ${sub.trialEnd?.toLocaleDateString() ?? 'soon'}.`,
severity: 'success',
action: { label: 'Add payment method', href: portalHref },
},
active: {
label: 'Active',
description: `Renews ${sub.currentPeriodEnd.toLocaleDateString()}.`,
severity: 'success',
},
past_due: {
label: 'Payment failed',
description: 'We couldn\'t charge your card. We\'ll retry automatically.',
severity: 'warning',
action: { label: 'Update payment method', href: portalHref },
},
unpaid: {
label: 'Access suspended',
description: 'Payment retries exhausted. Update your payment method to restore access.',
severity: 'error',
action: { label: 'Update payment method', href: portalHref },
},
canceled: {
label: 'Canceled',
description: 'Your subscription has ended.',
severity: 'neutral',
action: { label: 'Resubscribe', href: '/billing/plans' },
},
incomplete: {
label: 'Payment pending',
description: 'Complete your payment to activate your subscription.',
severity: 'warning',
action: { label: 'Complete payment', href: portalHref },
},
incomplete_expired: {
label: 'Subscription expired',
description: 'Your trial ended without a payment method on file.',
severity: 'error',
action: { label: 'Start a new plan', href: '/billing/plans' },
},
};
return map[sub.status] ?? {
label: 'Unknown', description: '', severity: 'neutral',
};
}
5. Optimistic Status Update During Checkout
After checkout.session.completed, the webhook typically arrives 1–10 seconds after the redirect. During that window, the user sees their old plan — causing confusion.
Fix: optimistic status on the success page, reconcile with DB after
// success page — /billing/success?session_id=xxx
async function BillingSuccessPage({ searchParams }) {
const sessionId = searchParams.session_id;
// Fetch session from Stripe directly on the success page — fast path
// Non-obvious: this is the ONE place it's acceptable to call Stripe directly,
// because the webhook may not have arrived yet
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status === 'paid') {
// Show success UI immediately without waiting for webhook
return <SuccessBanner plan={getPlanFromSession(session)} />;
}
// Payment still processing — show pending state
return <PendingBanner />;
}
// After rendering, poll your own DB until the webhook lands
// Client-side:
function usePlanSyncPoll(expectedPlan: string) {
const { data } = useQuery({
queryKey: ['subscription'],
queryFn: () => fetch('/api/billing/status').then(r => r.json()),
refetchInterval: (data) =>
data?.plan === expectedPlan ? false : 2000, // poll every 2s until synced, then stop
});
return data;
}
6. On-Demand Reconciliation — Fix It Now, Not Tonight
The nightly job catches drift eventually. When a user is actively blocked ("Stripe says I paid, why can't I access X?"), you need two things: a support endpoint to force-sync a single user, and a self-serve button so users can try themselves without filing a ticket.
// Support endpoint — internal only, gated by admin role
router.post('/api/admin/billing/reconcile/:userId', requireRole('admin'), async (req, res) => {
const sub = await db.subscription.findUnique({ where: { userId: req.params.userId } });
if (!sub?.stripeSubId) return res.status(404).json({ error: 'No subscription found' });
const stripeSub = await stripe.subscriptions.retrieve(sub.stripeSubId);
await db.subscription.update({
where: { id: sub.id },
data: {
status: stripeSub.status,
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
lastWebhookAt: new Date(),
},
});
res.json({ previous: sub.status, current: stripeSub.status });
});
// Self-serve endpoint — rate-limited so users can't hammer Stripe
const reconcileRateLimit = rateLimit({ windowMs: 60_000, max: 3 }); // 3 per minute
router.post('/api/billing/refresh', reconcileRateLimit, async (req, res) => {
const sub = await db.subscription.findUnique({ where: { userId: req.user.id } });
if (!sub?.stripeSubId) return res.json({ status: 'free' });
const stripeSub = await stripe.subscriptions.retrieve(sub.stripeSubId);
await db.subscription.update({
where: { id: sub.id },
data: {
status: stripeSub.status,
currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
lastWebhookAt: new Date(),
},
});
res.json({ status: stripeSub.status });
});
// "Refresh billing status" button on the billing page
function BillingStatusRefresh() {
const [refreshing, setRefreshing] = useState(false);
const queryClient = useQueryClient();
async function handleRefresh() {
setRefreshing(true);
await fetch('/api/billing/refresh', { method: 'POST' });
await queryClient.invalidateQueries({ queryKey: ['subscription'] });
setRefreshing(false);
}
return (
<button onClick={handleRefresh} disabled={refreshing}>
{refreshing ? 'Checking...' : 'Refresh billing status'}
</button>
);
}
Non-obvious: the self-serve endpoint must be rate-limited — without it, a confused user hitting refresh repeatedly generates a Stripe API call per click, which can exhaust rate limits during an incident when many users are affected simultaneously.
Output
Produce:
lastWebhookAtcolumn added to subscription model + all handlers updated to usestripeCustomerIdlookup and timestamp guardlib/reconcile.ts— nightly job with stale detection and per-record error isolationapi/billing/refresh.ts— rate-limited self-serve sync endpoint + admin force-sync endpointlib/subscriptionDisplay.ts— full status → display mapping includingcancelAtPeriodEndcheckBillingSuccessPagewith optimistic Stripe session fetch + polling hookBillingStatusRefreshbutton component with query invalidation
Flag clearly in comments:
- Every
stripeCustomerIdlookup explaining whystripeSubIdis unsafe for ordering reasons - The
updateMany+lastWebhookAtguard explaining the stale-overwrite risk - The rate limit on the self-serve endpoint and why it matters during incidents
- The success page Stripe call as the only acceptable direct Stripe call outside of write operations