The three pieces you actually need
A checkout flow that sends the user to Stripe, a webhook endpoint that listens for what happens next, and a database that stays in sync with whatever Stripe reports - that's the whole system, even though it can feel like more moving parts than it is.
Creating a checkout session
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: process.env.STRIPE_PRICE_ID, quantity: 1 }],
customer_email: user.email,
success_url: `${process.env.APP_URL}/billing?success=true`,
cancel_url: `${process.env.APP_URL}/billing?canceled=true`,
metadata: { userId: user._id.toString() },
});
Why webhooks matter more than the redirect
The success URL only tells you the user was redirected back - it doesn't guarantee payment actually succeeded. Stripe's webhooks are the source of truth, so your subscription logic should live there, not in the page the user lands on.
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await activateSubscription(session.metadata.userId, session.customer);
}
return NextResponse.json({ received: true });
}
Keep plan changes and cancellations in the webhook too
Handle customer.subscription.updated and customer.subscription.deleted the same way - update your database in response to what Stripe reports, rather than trying to predict it from your own UI actions.
One thing that trips people up
Always verify the webhook signature before trusting the payload. Without it, anyone who finds your webhook URL could send a fake "payment succeeded" event and unlock a subscription for free.
Comments
Loading comments...
Related Articles
Taming Slow MongoDB Aggregation Pipelines in Production
An aggregation pipeline that returns instantly on your 500-document local dataset can crawl once it hits a few million real documents. Here's how to actually find and fix the stage that's costing you.
Read ArticleBuilding a Sliding-Window Rate Limiter for Node.js APIs with Redis
Fixed-window rate limiting looks fine in a demo and then lets through double the traffic right at the window boundary. Here's the sliding-window-counter approach that fixes that, built directly on Redis.
Read ArticleMulti-Tenant Data Isolation: Shared Schema vs Separate Databases
Every multi-tenant SaaS eventually has to answer one question honestly: how do you stop one customer from ever seeing another customer's data? Here's how the three common isolation strategies actually compare in practice.
Read Article