Stripe Integration Guide: Payments, Subscriptions, and Connect
We have integrated Stripe into more projects than we can count. One-time payments for JustTheRip, subscription billing for LancerSpace, and marketplace payouts via Stripe Connect for Vincelio. Each integration has its own shape, but the fundamentals are the same.
This post covers the three most common Stripe patterns we implement, with TypeScript code examples and the lessons we have learned from production.
Stripe Checkout: The Fast Path
If you need to accept a payment and do not need a custom UI, Stripe Checkout is the right starting point. It is a hosted payment page that Stripe maintains — you redirect the customer there, they pay, and Stripe redirects them back. You do not handle card numbers, PCI compliance is simplified, and the conversion rate is excellent because Stripe optimizes the page constantly.
Here is a minimal Checkout Session for a one-time payment:
// src/api/create-checkout.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createCheckoutSession(
priceInCents: number,
productName: string,
customerEmail: string
) {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card'],
customer_email: customerEmail,
line_items: [
{
price_data: {
currency: 'usd',
unit_amount: priceInCents,
product_data: {
name: productName,
},
},
quantity: 1,
},
],
success_url: `${process.env.APP_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/payment/cancel`,
metadata: {
// Store your own reference IDs here
internal_order_id: 'order_abc123',
},
});
return session.url;
}
A few things to note. The {CHECKOUT_SESSION_ID} template in success_url is a Stripe feature — they replace it with the actual session ID at redirect time. Use this on your success page to verify the payment actually completed. Never trust a redirect alone.
// src/api/verify-payment.ts
export async function verifyPayment(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status === 'paid') {
// Payment confirmed — fulfill the order
return { success: true, email: session.customer_email };
}
return { success: false };
}
We used Checkout for the initial launch of JustTheRip because we wanted to validate the product before investing in a custom payment flow. Within a week we had payments working, including support for Apple Pay and Google Pay — which Checkout enables by default.

Payment Intents: The Custom Path
When you need a custom payment UI — your own form, embedded card elements, multi-step checkout — you use the Payment Intents API directly. This is more work but gives you full control over the experience.
The flow has three steps:
- Server: Create a PaymentIntent and return the
client_secretto the frontend. - Client: Confirm the payment using Stripe Elements or the Payment Element.
- Server: Handle the webhook to confirm fulfillment.
Server-side setup:
// src/api/payment-intent.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createPaymentIntent(
amount: number,
currency: string,
metadata: Record<string, string>
) {
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
metadata,
automatic_payment_methods: {
enabled: true,
},
});
return {
clientSecret: paymentIntent.client_secret,
};
}
Client-side with the Payment Element:
// src/components/PaymentForm.ts
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe(process.env.STRIPE_PUBLISHABLE_KEY!);
async function initializePayment(clientSecret: string) {
const stripe = await stripePromise;
if (!stripe) throw new Error('Stripe failed to load');
const elements = stripe.elements({
clientSecret,
appearance: {
theme: 'stripe',
variables: {
colorPrimary: '#3b82f6',
borderRadius: '8px',
},
},
});
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
return { stripe, elements };
}
async function handleSubmit(
stripe: any,
elements: any,
returnUrl: string
) {
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl,
},
});
if (error) {
// Show error to customer
console.error(error.message);
}
// If no error, customer is redirected to return_url
}
The Payment Element is the modern approach — it renders a single, adaptive form that handles cards, bank transfers, wallets, and regional payment methods. Use it instead of the older Card Element unless you have a specific reason not to.
Subscriptions: Recurring Revenue
Subscription billing is where Stripe’s complexity starts to show. You are dealing with trials, plan changes, proration, failed payments, cancellations, and dunning. Getting this right requires understanding the full lifecycle.
Here is how we set up subscriptions:
// src/api/subscription.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createSubscription(
customerId: string,
priceId: string,
trialDays?: number
) {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
trial_period_days: trialDays,
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
});
const invoice = subscription.latest_invoice as Stripe.Invoice;
const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
return {
subscriptionId: subscription.id,
clientSecret: paymentIntent?.client_secret,
status: subscription.status,
};
}
The payment_behavior: 'default_incomplete' pattern is important. It creates the subscription in an incomplete state and returns a client_secret so the customer can confirm payment on the frontend. The subscription only activates when payment succeeds. This prevents creating subscriptions that immediately fail.
For LancerSpace, we implemented tiered subscriptions with a free plan, a pro plan, and an enterprise plan. The key lesson was to keep your plan structure simple. Every tier change, proration rule, and edge case multiplies your webhook handling complexity.
Managing Plan Changes
export async function updateSubscription(
subscriptionId: string,
newPriceId: string
) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const updatedSubscription = await stripe.subscriptions.update(
subscriptionId,
{
items: [
{
id: subscription.items.data[0].id,
price: newPriceId,
},
],
proration_behavior: 'always_invoice',
}
);
return updatedSubscription;
}
The proration_behavior setting determines how Stripe handles the cost difference when a customer changes plans mid-cycle. always_invoice generates an immediate invoice for the difference. create_prorations adds line items to the next invoice. none skips proration entirely. Pick the one that matches your billing policy and document it clearly.

Stripe Connect: Marketplace Payments
Stripe Connect is what you use when your platform facilitates payments between buyers and sellers. This was the most complex Stripe integration we have done, for Vincelio — a marketplace connecting brands with creators in Latin America.
Connect has three account types. For most marketplaces, you want Express accounts. They give your sellers a Stripe-hosted onboarding flow and dashboard while you control the payment flow.
Onboarding Sellers
// src/api/connect.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createConnectAccount(
email: string,
userId: string
) {
const account = await stripe.accounts.create({
type: 'express',
email,
metadata: { user_id: userId },
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
});
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${process.env.APP_URL}/connect/refresh`,
return_url: `${process.env.APP_URL}/connect/complete`,
type: 'account_onboarding',
});
return {
accountId: account.id,
onboardingUrl: accountLink.url,
};
}
Processing Marketplace Payments
When a buyer pays on your platform, you create a PaymentIntent with a transfer to the seller:
export async function createMarketplacePayment(
amount: number,
sellerAccountId: string,
platformFeePercent: number
) {
const platformFee = Math.round(amount * (platformFeePercent / 100));
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency: 'usd',
automatic_payment_methods: { enabled: true },
application_fee_amount: platformFee,
transfer_data: {
destination: sellerAccountId,
},
metadata: {
seller_account: sellerAccountId,
platform_fee: platformFee.toString(),
},
});
return {
clientSecret: paymentIntent.client_secret,
};
}
The application_fee_amount is your platform’s cut. Stripe handles splitting the payment — the seller receives amount - application_fee_amount - Stripe's processing fee, and your platform receives the application fee.
One lesson from Vincelio: always store the sellerAccountId in your own database and verify it before creating payments. If a seller’s account gets restricted or deactivated by Stripe, you need to handle that gracefully.

Webhooks: The Part Everyone Gets Wrong
Stripe webhooks are how you get notified about events — successful payments, failed charges, subscription changes, dispute notifications. They are also the most common source of bugs in Stripe integrations.
// src/api/webhooks/stripe.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function handleStripeWebhook(
body: string,
signature: string
) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
} catch (err) {
throw new Error(`Webhook signature verification failed: ${err}`);
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillOrder(session);
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
await activateSubscription(invoice);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handleFailedPayment(invoice);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await deactivateSubscription(subscription);
break;
}
default:
// Unhandled event type — log and return 200
console.log(`Unhandled event type: ${event.type}`);
}
}
Critical rules for webhooks:
- Always verify the signature. Never trust the payload without verifying it came from Stripe.
- Return 200 immediately. Do your processing asynchronously or in a queue. If your handler takes too long, Stripe will retry and you will get duplicate events.
- Make handlers idempotent. Stripe may send the same event multiple times. Use the event ID or the object’s metadata to prevent double-processing.
- Handle events in the right order. A
customer.subscription.updatedmight arrive beforeinvoice.payment_succeeded. Your code needs to handle any order.
// Idempotent webhook handler example
async function fulfillOrder(session: Stripe.Checkout.Session) {
const orderId = session.metadata?.internal_order_id;
if (!orderId) return;
// Check if already fulfilled — this is the idempotency check
const order = await db.orders.findById(orderId);
if (order.status === 'fulfilled') return;
await db.orders.update(orderId, {
status: 'fulfilled',
stripe_session_id: session.id,
fulfilled_at: new Date(),
});
await sendConfirmationEmail(session.customer_email!);
}
Idempotency Keys
For server-to-server API calls, Stripe supports idempotency keys. If your server crashes after sending a request but before receiving the response, you can safely retry with the same idempotency key and Stripe will return the original response instead of creating a duplicate.
const paymentIntent = await stripe.paymentIntents.create(
{
amount: 2000,
currency: 'usd',
customer: customerId,
},
{
idempotencyKey: `order_${orderId}_payment`,
}
);
We generate idempotency keys from our own internal IDs. This way, if the same order triggers two payment attempts, only one PaymentIntent is created.
Testing and Development
Stripe’s test mode is excellent, but there are a few practices we follow:
- Use the Stripe CLI for local webhook testing.
stripe listen --forward-to localhost:3000/api/webhooks/stripeforwards events to your local server. - Test with specific card numbers.
4242424242424242succeeds,4000000000000002declines,4000000000003220triggers 3D Secure. Know these by heart. - Test subscription lifecycles end-to-end. Create a subscription, advance the test clock, verify invoicing and renewals. Stripe’s test clocks let you simulate time passing without waiting.
# Stripe CLI for local development
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
Common Mistakes
Trusting the redirect URL. After Checkout, customers land on your success page. Some developers mark the order as complete at this point. Do not. The success page is for UX only. Fulfillment must happen in the webhook handler.
Not handling failed payments for subscriptions. Stripe retries failed payments on a schedule (Smart Retries). But you still need to handle invoice.payment_failed to notify the customer and potentially restrict access.
Hardcoding prices. Use Stripe’s Price objects and reference them by ID. This lets you change pricing without deploying code. Create prices in the Stripe Dashboard and store the IDs in environment variables.
Skipping idempotency. In production, network errors, timeouts, and retries happen. Every mutating API call should include an idempotency key.
Not logging webhook events. Store every webhook event you receive, including the full payload. When something goes wrong — and it will — these logs are the first thing you need.
Choosing the Right Pattern
- Selling a product or service with a simple checkout? Stripe Checkout. Get to market fast.
- Need a custom payment experience embedded in your app? Payment Intents with the Payment Element.
- SaaS with recurring billing? Subscriptions with Checkout for the initial payment.
- Marketplace with buyer-seller payments? Connect with Express accounts.
Start with the simplest integration that meets your requirements. You can always add complexity later. We have migrated clients from Checkout to custom Payment Intents when they outgrew the hosted page, and the transition was straightforward because the underlying PaymentIntent API is the same.
If you are building a product that needs payments and want to get it right the first time, reach out at [email protected]. We have shipped enough Stripe integrations to know where the edge cases hide.