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
↓
Claim an idempotency key before side effects
↓
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 |
Idempotency Model
Stripe can deliver the same webhook more than once. Plainform protects handlers that perform durable side effects with a small Prisma-backed idempotency table.
The helper in lib/stripe/webhooks/idempotency.ts claims a unique key before work starts, marks it processed after success, and marks it failed if the handler throws. Failed keys can be reclaimed on a later retry.
Plainform writes idempotency rows for these side-effectful actions:
| Key | Purpose |
|---|---|
stripe:checkout-session-completed:{session.id} | One-time checkout completion, capture, metadata, and email side effects |
stripe:subscription-checkout-session-completed:{session.id} | Subscription checkout metadata and checkout-level side effects |
stripe:subscription-created:{subscription.id} | Created-only subscription sync and future created-only side effects |
stripe:subscription-updated:{event.id} | Exact retry protection for each subscription update event |
stripe:subscription-deleted:{subscription.id} | Ended-subscription cleanup and future deleted-only side effects |
Ignored Stripe events such as charge.*, payment_intent.*, and mandate.* are not stored in the idempotency table.
Do not rely on Stripe Checkout session metadata as your only duplicate guard. Metadata is useful for the order page, but it is not an atomic lock. Use a durable unique key for emails, provisioning, credits, or other side effects.
Handler Structure
The webhook route keeps one handler per important event:
switch (event.type) {
case 'checkout.session.completed':
response = await handleCheckoutSessionCompleted(
event.data.object as Stripe.Checkout.Session,
event.id
);
break;
case 'customer.subscription.created':
response = await handleSubscriptionCreated(
event.data.object as Stripe.Subscription,
event.id
);
break;
case 'customer.subscription.updated':
response = await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription,
event.id
);
break;
case 'customer.subscription.deleted':
response = await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription,
event.id
);
break;
}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,
stripeEventId: string
) {
if (session.mode === 'payment') {
return await handleOneTimeCheckoutSessionCompleted(session, stripeEventId);
}
if (session.mode === 'subscription') {
return await handleSubscriptionCheckoutSessionCompleted(
session,
stripeEventId
);
}
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:
- Claims
stripe:checkout-session-completed:{session.id}before side effects. - Skips duplicate deliveries for the same checkout session.
- 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:
- Claims
stripe:subscription-checkout-session-completed:{session.id}before side effects. - Skips duplicate deliveries for the same checkout session.
- 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,
stripeEventId: string
) {
const key = `stripe:subscription-created:${subscription.id}`;
const claimed = await claimStripeWebhookKey({
key,
stripeEventId,
eventType: 'customer.subscription.created',
objectId: subscription.id,
});
if (!claimed) {
return NextResponse.json(
{ message: 'Subscription created event already processed.' },
{ status: 200 }
);
}
try {
const res = await syncSubscription(subscription);
if (!res.ok) {
await markStripeWebhookKeyFailed(
key,
`Subscription sync failed with status ${res.status}`
);
return res;
}
// Add created-only side effects here.
await markStripeWebhookKeyProcessed(key);
return res;
} catch (error) {
await markStripeWebhookKeyFailed(key, error);
throw error;
}
}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,
stripeEventId: string
) {
const key = `stripe:subscription-updated:${stripeEventId}`;
const claimed = await claimStripeWebhookKey({
key,
stripeEventId,
eventType: 'customer.subscription.updated',
objectId: subscription.id,
});
if (!claimed) {
return NextResponse.json(
{ message: 'Subscription updated event already processed.' },
{ status: 200 }
);
}
try {
const res = await syncSubscription(subscription);
if (!res.ok) {
await markStripeWebhookKeyFailed(
key,
`Subscription sync failed with status ${res.status}`
);
return res;
}
// Add update-only side effects here. For business-transition side effects,
// prefer a key such as subscriptionId + new price/status/cancel_at.
await markStripeWebhookKeyProcessed(key);
return res;
} catch (error) {
await markStripeWebhookKeyFailed(key, error);
throw error;
}
}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,
stripeEventId: string
) {
const key = `stripe:subscription-deleted:${subscription.id}`;
const claimed = await claimStripeWebhookKey({
key,
stripeEventId,
eventType: 'customer.subscription.deleted',
objectId: subscription.id,
});
if (!claimed) {
return NextResponse.json(
{ message: 'Subscription deleted event already processed.' },
{ status: 200 }
);
}
try {
const res = await clearSubscription(subscription);
if (!res.ok) {
await markStripeWebhookKeyFailed(
key,
`Subscription cleanup failed with status ${res.status}`
);
return res;
}
// Add ended-subscription side effects here.
await markStripeWebhookKeyProcessed(key);
return res;
} catch (error) {
await markStripeWebhookKeyFailed(key, error);
throw error;
}
}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