Every product that charges money ends up building the same things: a checkout flow, subscription management, webhook handlers, and invoice logic. Stripe provides all of this as an API, with a TypeScript SDK that makes the integration type-safe from session creation to event processing. The real complexity isn’t calling Stripe’s endpoints. It’s understanding the architecture: why webhooks are the source of truth, how subscription lifecycles work, and where to draw the line between Stripe’s hosted UI and your own.
Checkout sessions: the hosted payment flow
Stripe Checkout is a hosted payment page that handles card validation, 3D Secure, Apple Pay, Google Pay, and PCI compliance. You create a session on the server, redirect the user, and Stripe does the rest. No card number ever touches your infrastructure.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(priceId: string, customerEmail: string) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: customerEmail,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return session.url;
}
app.post('/api/checkout', async (req, res) => {
const { priceId, email } = req.body;
const url = await createCheckoutSession(priceId, email);
res.json({ url });
});
The mode parameter determines whether this is a one-time payment (payment) or a recurring charge (subscription). The success_url includes a {CHECKOUT_SESSION_ID} template that Stripe replaces with the actual session ID, allowing you to verify the payment on the success page. The secret key never leaves the server.
Webhooks: why redirects are not enough
A user completing Checkout and landing on your success page doesn’t mean the payment went through. They might close the tab, lose connection, or have their card declined after 3D Secure. Webhooks are Stripe’s way of telling your server what actually happened, asynchronously and reliably.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function handleWebhook(payload: string, signature: string) {
const event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await db.user.update({
where: { email: session.customer_email! },
data: {
stripeCustomerId: session.customer as string,
subscriptionStatus: 'active',
},
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await db.user.update({
where: { stripeCustomerId: invoice.customer as string },
data: { subscriptionStatus: 'past_due' },
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: subscription.customer as string },
data: { subscriptionStatus: 'canceled' },
});
break;
}
}
}
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
try {
await handleWebhook(req.body, req.headers['stripe-signature'] as string);
res.json({ received: true });
} catch {
res.status(400).send('Invalid signature');
}
});
The constructEvent call verifies that the payload was signed by Stripe. Without this check, anyone could forge events and manipulate your database. The raw body middleware is critical here: Stripe signs the raw payload, not the parsed JSON. If Express parses it first, the signature won’t match.
Three events cover most subscription lifecycles. checkout.session.completed fires when a customer finishes paying. invoice.payment_failed fires when a renewal charge fails. customer.subscription.deleted fires when a subscription ends, whether from cancellation or repeated payment failures.
Subscription lifecycle: what happens after checkout
A subscription isn’t a single event. It’s a state machine that moves through active, past_due, canceled, and unpaid states over its lifetime. Understanding these transitions is what separates a working Stripe integration from a production-ready one.
export async function getSubscriptionStatus(customerId: string) {
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 1,
});
const subscription = subscriptions.data[0];
if (!subscription) return 'none';
return subscription.status;
}
export async function cancelSubscription(subscriptionId: string) {
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
Setting cancel_at_period_end: true instead of deleting the subscription immediately lets the customer use the service until their current billing period ends. This is the expected behavior for most SaaS products. The customer.subscription.updated webhook fires with the cancel_at_period_end flag, and customer.subscription.deleted fires when the period actually ends.
Customer portal: zero billing UI to build
Stripe hosts a customer portal where users can update their payment method, switch plans, view invoices, and cancel. You configure what’s allowed in the Stripe dashboard and redirect users there.
export async function createPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.APP_URL}/dashboard`,
});
return session.url;
}
Every action the customer takes in the portal generates webhook events. Plan changes trigger customer.subscription.updated. Payment method updates trigger payment_method.attached. Cancellations follow the same lifecycle as programmatic cancellations. Your webhook handler stays the single source of truth for all state changes.
Payment Intents: one-time charges
Subscriptions aren’t the only model. For one-time purchases, Payment Intents give full control over the payment flow while keeping card details off your server.
export async function createPaymentIntent(amount: number, currency: string) {
const intent = await stripe.paymentIntents.create({
amount: amount * 100,
currency,
automatic_payment_methods: { enabled: true },
});
return { clientSecret: intent.client_secret };
}
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
function PaymentForm() {
const stripe = useStripe();
const elements = useElements();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!stripe || !elements) return;
await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/success` },
});
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit">Pay</button>
</form>
);
}
The server creates the intent and returns a client secret. The frontend uses Stripe Elements to collect payment details and confirm. The PaymentElement component adapts to the customer’s region, showing relevant payment methods like iDEAL, Bancontact, or SEPA debit alongside cards. Card numbers go directly to Stripe, never through your server.
Testing locally with the Stripe CLI
Stripe’s CLI forwards webhook events to your local server. No tunneling service, no deployment needed.
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Trigger specific events to test your handlers in isolation.
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
Each command sends a realistic payload with proper signatures. Combined with Stripe’s test mode API keys (prefixed with sk_test_), you can simulate the entire payment lifecycle, successful charges, failed renewals, cancellations, without processing real money. Always verify that your webhook handler updates the database correctly for each event before going live.
Conclusion
Stripe handles the hard parts of payments: PCI compliance, payment method diversity, subscription state machines, and hosted billing UI. The developer’s job is to wire the SDK correctly, trust webhooks as the source of truth, and keep the database in sync with Stripe’s events. The TypeScript SDK makes every API call type-safe, and the Stripe CLI makes local testing painless. For any project that needs to charge users, Stripe removes months of infrastructure work and lets you ship the product.