Stripe Webhook
Understand Plainform's Stripe webhook flow for products, coupons, checkout sessions, and subscriptions.
Plainform receives Stripe events at app/api/webhooks/stripe/route.ts. The route verifies the Stripe signature, routes each event to a small handler, and revalidates the cache tags that power pricing and coupon display.
Data Flow
Stripe event
↓
POST /api/webhooks/stripe
↓
Verify stripe-signature with STRIPE_WEBHOOK_SECRET
↓
Switch on event.type
↓
Run one handler for that event
↓
Update database, metadata, emails, and cache tagsThe route uses req.text() because Stripe signature verification requires the
raw request body. Do not replace it with req.json().
Events Plainform Handles
| Event | What Plainform Does |
|---|---|
checkout.session.completed | Delegates by checkout mode: one-time payment or subscription checkout |
customer.subscription.created | Syncs subscription state into the local User row |
customer.subscription.updated | Updates status, plan, period end, and cancellation state |
customer.subscription.deleted | Clears subscription fields and marks the user canceled |
product.created, product.updated, product.deleted | Revalidates cached Stripe products |
coupon.created, coupon.updated, coupon.deleted | Revalidates cached coupons and updates featured coupon events |
Handler Structure
The webhook route keeps one handler per important event:
switch (event.type) {
case 'checkout.session.completed':
return await handleCheckoutSessionCompleted(
event.data.object as Stripe.Checkout.Session
);
case 'customer.subscription.created':
return await handleSubscriptionCreated(
event.data.object as Stripe.Subscription
);
case 'customer.subscription.updated':
return await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
case 'customer.subscription.deleted':
return await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
}This makes the webhook easier to extend. Add checkout-level logic in checkout handlers, subscription state logic in syncSubscription, and event-specific side effects in the matching event handler.
Checkout Completion
handleCheckoutSessionCompleted() only decides which checkout flow should run:
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session
) {
if (session.mode === 'payment') {
return await handleOneTimeCheckoutSessionCompleted(session);
}
if (session.mode === 'subscription') {
return await handleSubscriptionCheckoutSessionCompleted(session);
}
return NextResponse.json({ message: 'Checkout session mode not handled' });
}One-Time Checkout
handleOneTimeCheckoutSessionCompleted() handles one-time purchases. Plainform uses manual capture for one-time payments, so this handler:
- Checks if the session was already processed.
- Validates that Stripe sent a customer email.
- Captures the PaymentIntent with
capturePaymentIntent(). - Cancels the PaymentIntent with
cancelPaymentIntent()if validation or capture fails. - Updates checkout session metadata used by the order page.
- Adds the customer email to contacts.
- Sends success or failure email.
- Revalidates
stripe/couponsandstripe/customers.
Add product-specific validation before capturePaymentIntent() if your app needs inventory checks, fraud review, required profile data, or custom fulfillment rules.
Subscription Checkout
handleSubscriptionCheckoutSessionCompleted() does not sync entitlements. It keeps checkout-level work separate:
- Checks if the session was already processed.
- Updates checkout session metadata.
- Revalidates coupon and customer cache tags.
Subscription access is synced by customer.subscription.* events because those events are the durable source for subscription status, renewal, cancellation, and plan changes.
Subscription Sync
customer.subscription.created and customer.subscription.updated both call syncSubscription().
await prisma.user.upsert({
where: { id: clerkUserId },
update: {
email,
stripeCustomerId: customerId,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
subscriptionStatus: subscription.status,
subscriptionCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
cancelAtPeriodEnd: isCanceling(subscription),
planKey,
},
create: {
id: clerkUserId,
email,
stripeCustomerId: customerId,
stripeSubscriptionId: subscription.id,
stripePriceId: priceId,
subscriptionStatus: subscription.status,
subscriptionCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
cancelAtPeriodEnd: isCanceling(subscription),
planKey,
},
});The function retrieves the Stripe customer, reads customer.metadata.clerkUserId, then writes subscription state to the matching local user.
The checkout route stores clerkUserId on the Stripe customer. Keep that
metadata intact, because subscription webhooks use it to find the local user.
Where To Add App Logic
Use the smallest handler that owns the behavior:
syncSubscription()- Durable entitlement fields such as seats, credits, feature limits, plan keys, or usage caps.handleSubscriptionCreated()- Created-only side effects like onboarding records, welcome emails, or audit logs.handleSubscriptionUpdated()- Upgrade, downgrade, renewal, cancellation scheduled, or billing-change side effects.handleSubscriptionDeleted()- End-of-access cleanup, cancellation emails, resource archiving, or downgrade cleanup.handleOneTimeCheckoutSessionCompleted()- One-time payment validation, fulfillment, and manual capture logic.handleSubscriptionCheckoutSessionCompleted()- Checkout-level subscription side effects that should not be treated as the source of entitlement state.
Handler Reference
syncSubscription()
Use syncSubscription() for data your app needs to check on every request. This is where you should persist plan limits and entitlements derived from Stripe product or price metadata.
const planKey = product.metadata?.planKey ?? price.lookup_key ?? null;
const maxProjects = product.metadata?.maxProjects ?? '0';
await prisma.user.upsert({
where: { id: clerkUserId },
update: {
subscriptionStatus: subscription.status,
subscriptionCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
cancelAtPeriodEnd: isCanceling(subscription),
planKey,
// maxProjects,
},
create: {
id: clerkUserId,
email,
subscriptionStatus: subscription.status,
subscriptionCurrentPeriodEnd: getSubscriptionPeriodEnd(subscription),
cancelAtPeriodEnd: isCanceling(subscription),
planKey,
// maxProjects,
},
});Put durable app state here because both customer.subscription.created and customer.subscription.updated call this function.
handleSubscriptionCreated()
Use this handler for actions that should happen only when a subscription first starts. Add your logic after syncSubscription() succeeds:
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription
) {
const res = await syncSubscription(subscription);
if (!res.ok) {
return res;
}
// Add created-only side effects here:
// await sendWelcomeEmail(subscription);
// await createOnboardingChecklist(subscription);
// await recordBillingAuditLog(subscription, 'subscription_created');
return res;
}handleSubscriptionUpdated()
Use this handler for changes after the subscription exists: upgrades, downgrades, renewals, payment recovery, or scheduled cancellation.
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription
) {
const res = await syncSubscription(subscription);
if (!res.ok) {
return res;
}
// Add update-only side effects here:
// if (subscription.cancel_at_period_end) await sendCancellationScheduledEmail();
// await notifyPlanChanged(subscription);
// await resetUsageIfPlanChanged(subscription);
return res;
}handleSubscriptionDeleted()
Use this handler after Stripe says the subscription has ended. Plainform clears subscription fields first, then you can clean up app state.
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
) {
const res = await clearSubscription(subscription);
if (!res.ok) {
return res;
}
// Add ended-subscription side effects here:
// await sendCancellationEmail(subscription);
// await archivePaidResources(subscription);
// await downgradeWorkspace(subscription);
return res;
}handleOneTimeCheckoutSessionCompleted()
Use this handler for one-time payment fulfillment. Add validation before capture, and fulfillment after capture.
// Validate before capture
const isValid = await validateOrder(session);
if (!isValid) {
if (paymentIntentId) await cancelPaymentIntent(paymentIntentId);
return NextResponse.json({ message: 'Validation failed' }, { status: 400 });
}
if (paymentIntentId) {
const captureResult = await capturePaymentIntent(paymentIntentId);
if (!captureResult.ok) {
return NextResponse.json(
{ message: 'Payment processing failed' },
{ status: 400 }
);
}
}
// Fulfill after capture
// await provisionPurchase(session);
// await sendReceipt(session);capturePaymentIntent() and cancelPaymentIntent()
capturePaymentIntent() is the success path for one-time manual-capture payments. It checks the PaymentIntent state, captures only when the status is requires_capture, and treats already captured payments as a successful idempotent outcome.
cancelPaymentIntent() is the rollback path for uncaptured one-time payments. Call it when validation fails before capture or when your handler decides the order should not be accepted.
Do not use either helper for subscription checkout. Subscription payments are handled automatically by Stripe and synchronized through customer.subscription.* events.
For a more guided subscription example, see the recipe:
Related Resources
How is this guide ?
Last updated on