Payment integration is one of the highest-stakes technical decisions a product team makes. Get it wrong and you lose revenue to failed transactions, expose customer data, or create a checkout experience that kills conversion. Get it right and your payment infrastructure becomes invisible to customers, it simply works, at every scale, in every country, across every card network.
Stripe is the most complete payment platform available to developers. It handles card processing, subscription billing, invoicing, customer portals, fraud prevention, tax calculation, and payouts, all through a single, well-documented API. But the breadth of the platform means there are many ways to integrate it, and several patterns that look correct on the surface but cause serious problems in production.
This guide covers the full integration lifecycle: choosing the right Stripe product for your use case, building checkout flows, handling webhooks correctly, managing subscriptions, recovering from failed payments, and securing the integration against fraud and misconfiguration. Code examples are provided for Node.js, NestJS, Laravel, and FastAPI.
Overview
Stripe's API is organized around a set of core objects: Customers, PaymentIntents, SetupIntents, Subscriptions, Invoices, and Checkout Sessions. For most integrations, you will interact with a subset of these. One-time payments use PaymentIntents or Checkout Sessions. Recurring billing uses Subscriptions, which automatically generate Invoices that attempt payment on a schedule. Webhooks deliver asynchronous event notifications for every state change across all these objects.
Stripe operates in two modes: test mode (all keys prefixed with sk_test_ and pk_test_) and live mode (sk_live_ and pk_live_). Test mode uses a set of predefined card numbers that simulate different outcomes, successful payments, declines, 3D Secure authentication, insufficient funds. Always build and test in test mode first; switch to live mode only when deploying to production.
- One-time payments, a customer pays once for a product or service
- Subscription billing, recurring monthly or annual charges for SaaS memberships
- Usage-based billing, charge based on how much a customer consumes per billing period
- Marketplaces, split payments between a platform and connected seller accounts (Stripe Connect)
- Customer portals, let customers manage their own subscription, payment method, and invoices
- Invoice automation, generate and send invoices automatically for B2B billing scenarios
Architecture Overview
The core principle in any Stripe integration: never trust the frontend to determine payment success. A user can manipulate JavaScript, intercept network requests, or modify URL parameters on a success redirect. Payment status must always be determined by your backend, either by calling the Stripe API directly or, better, by receiving a webhook event from Stripe.
flowchart LR
Customer["Customer"] --> FE["Frontend\nReact / Next.js / Vue"]
FE --> BE["Backend API\nNode.js / FastAPI"]
BE --> Stripe["Stripe API"]
Stripe --> Hook["Webhook\nPOST /stripe/webhook"]
Hook --> BE
BE --> DB["Database\nPostgreSQL"]
BE --> Mail["Email Service\nSendGrid / Resend"]
Stripe --> Portal["Customer Portal"]
Portal --> CustomerNever provision access, ship orders, or activate subscriptions based on a successful redirect URL. The success URL fires even if the user closes the browser mid-payment. Always wait for the corresponding webhook event before fulfilling the order.
Request Flow, Checkout Session
- 1User clicks 'Subscribe' or 'Buy' on the frontend
- 2Frontend POSTs the selected price ID to your backend
- 3Backend creates a Checkout Session via the Stripe API and returns the hosted URL
- 4Frontend redirects the user to the Stripe-hosted checkout page
- 5User enters payment details on Stripe's secure page
- 6Stripe processes the payment and redirects to your success_url
- 7Stripe fires a checkout.session.completed webhook event to your server
- 8Your webhook handler fulfills the order and updates the database
Frontend Integration
The frontend's role in a Stripe integration is to initiate the payment flow and display the result. For Checkout Sessions, the frontend calls your backend to get a URL and redirects, Stripe hosts the entire payment page. For Payment Elements, Stripe's SDK renders the card fields directly in your UI.
React, Checkout Redirect
npm install @stripe/stripe-js @stripe/react-stripe-jsimport { useState } from 'react'
interface CheckoutButtonProps {
priceId: string
label?: string
}
export function CheckoutButton({ priceId, label = 'Subscribe' }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false)
const handleCheckout = async () => {
setLoading(true)
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url, error } = await res.json()
if (error) throw new Error(error)
window.location.href = url
} catch (err) {
alert('Checkout failed. Please try again.')
setLoading(false)
}
}
return (
<button onClick={handleCheckout} disabled={loading}>
{loading ? 'Redirecting...' : label}
</button>
)
}React, Embedded Payment Element
Use the Payment Element when you want to embed card fields directly in your UI instead of redirecting to Stripe's hosted page. The Payment Element automatically supports cards, wallets (Apple Pay, Google Pay), and regional payment methods.
import { useState } from 'react'
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
function CheckoutForm() {
const stripe = useStripe()
const elements = useElements()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!stripe || !elements) return
setLoading(true)
setError(null)
const { error: stripeError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.origin + '/payment/success',
},
})
if (stripeError) {
setError(stripeError.message ?? 'Payment failed.')
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
<button type="submit" disabled={!stripe || loading}>
{loading ? 'Processing...' : 'Pay now'}
</button>
</form>
)
}
export function PaymentForm({ clientSecret }: { clientSecret: string }) {
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm />
</Elements>
)
}Next.js, Checkout API Route
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20' })
export async function POST(req: NextRequest) {
const session = await getServerSession()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { priceId } = await req.json()
if (!priceId) return NextResponse.json({ error: 'Missing priceId' }, { status: 400 })
const user = await db.users.findUnique({ where: { email: session.user.email! } })
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: user?.stripeCustomerId ?? undefined,
customer_email: user?.stripeCustomerId ? undefined : session.user.email!,
line_items: [{ price: priceId, quantity: 1 }],
success_url: process.env.NEXT_PUBLIC_BASE_URL + '/dashboard?checkout=success',
cancel_url: process.env.NEXT_PUBLIC_BASE_URL + '/pricing',
metadata: { userId: user!.id },
subscription_data: { metadata: { userId: user!.id } },
})
return NextResponse.json({ url: checkoutSession.url })
}Vue.js, Checkout Composable
import { ref } from 'vue'
export function useStripeCheckout() {
const loading = ref(false)
const error = ref<string | null>(null)
const startCheckout = async (priceId: string) => {
loading.value = true
error.value = null
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
if (!res.ok) throw new Error('Server error')
const { url } = await res.json()
window.location.href = url
} catch {
error.value = 'Checkout failed. Please try again.'
loading.value = false
}
}
const openPortal = async () => {
const res = await fetch('/api/stripe/portal', { method: 'POST' })
const { url } = await res.json()
window.location.href = url
}
return { loading, error, startCheckout, openPortal }
}Angular
import { Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { tap } from 'rxjs/operators'
@Injectable({ providedIn: 'root' })
export class StripeService {
constructor(private http: HttpClient) {}
startCheckout(priceId: string) {
return this.http
.post<{ url: string }>('/api/stripe/checkout', { priceId })
.pipe(tap(({ url }) => (window.location.href = url)))
}
openPortal() {
return this.http
.post<{ url: string }>('/api/stripe/portal', {})
.pipe(tap(({ url }) => (window.location.href = url)))
}
}Svelte
<script lang="ts">
export let priceId: string
export let label = 'Subscribe'
let loading = false
async function startCheckout() {
loading = true
try {
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url
} catch {
loading = false
}
}
</script>
<button on:click={startCheckout} disabled={loading}>
{loading ? 'Redirecting...' : label}
</button>Solid.js
import { createSignal } from 'solid-js'
export function CheckoutButton({ priceId, label = 'Subscribe' }: { priceId: string; label?: string }) {
const [loading, setLoading] = createSignal(false)
const startCheckout = async () => {
setLoading(true)
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url
}
return (
<button onClick={startCheckout} disabled={loading()}>
{loading() ? 'Redirecting...' : label}
</button>
)
}Qwik
import { component$, useSignal, $ } from '@builder.io/qwik'
export const CheckoutButton = component$<{ priceId: string; label?: string }>(
({ priceId, label = 'Subscribe' }) => {
const loading = useSignal(false)
const startCheckout = $(async () => {
loading.value = true
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url
})
return (
<button onClick$={startCheckout} disabled={loading.value}>
{loading.value ? 'Redirecting...' : label}
</button>
)
}
)Astro
---
const { priceId, label = 'Subscribe' } = Astro.props
---
<button data-price-id={priceId} id="checkout-btn">{label}</button>
<script>
document.getElementById('checkout-btn')?.addEventListener('click', async (e) => {
const btn = e.currentTarget as HTMLButtonElement
const priceId = btn.dataset.priceId
btn.textContent = 'Redirecting...'
btn.disabled = true
const res = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url
})
</script>Backend Integration
The backend creates Stripe objects, verifies webhooks, and syncs payment state to your database. The Stripe SDK handles all the HTTP communication, you only need to configure it with your secret key.
Node.js / Express
npm install stripeimport Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
})
export async function getOrCreateCustomer(userId: string, email: string): Promise<string> {
const existing = await db.users.findUnique({ where: { id: userId } })
if (existing?.stripeCustomerId) return existing.stripeCustomerId
const customer = await stripe.customers.create({ email, metadata: { userId } })
await db.users.update({
where: { id: userId },
data: { stripeCustomerId: customer.id },
})
return customer.id
}
export async function createCheckoutSession(userId: string, email: string, priceId: string) {
const customerId = await getOrCreateCustomer(userId, email)
return stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: process.env.BASE_URL + '/dashboard?checkout=success',
cancel_url: process.env.BASE_URL + '/pricing',
metadata: { userId },
subscription_data: { metadata: { userId } },
allow_promotion_codes: true,
})
}
export async function createPortalSession(customerId: string) {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: process.env.BASE_URL + '/dashboard/billing',
})
return session.url
}NestJS
import { Injectable, RawBodyRequest } from '@nestjs/common'
import Stripe from 'stripe'
import type { Request } from 'express'
@Injectable()
export class StripeService {
readonly stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
})
async createCheckoutSession(userId: string, email: string, priceId: string) {
return this.stripe.checkout.sessions.create({
mode: 'subscription',
customer_email: email,
line_items: [{ price: priceId, quantity: 1 }],
success_url: process.env.BASE_URL + '/dashboard?checkout=success',
cancel_url: process.env.BASE_URL + '/pricing',
metadata: { userId },
})
}
constructWebhookEvent(req: RawBodyRequest<Request>): Stripe.Event {
const sig = req.headers['stripe-signature'] as string
return this.stripe.webhooks.constructEvent(
req.rawBody!,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
}
}Python FastAPI
pip install stripe fastapiimport os, stripe
from fastapi import APIRouter, Request, HTTPException
router = APIRouter()
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
@router.post("/checkout")
async def create_checkout(request: Request):
body = await request.json()
session = stripe.checkout.Session.create(
mode="subscription",
customer_email=body["email"],
line_items=[{"price": body["price_id"], "quantity": 1}],
success_url=os.environ["BASE_URL"] + "/dashboard?checkout=success",
cancel_url=os.environ["BASE_URL"] + "/pricing",
metadata={"user_id": body["user_id"]},
subscription_data={"metadata": {"user_id": body["user_id"]}},
)
return {"url": session.url}
@router.post("/webhook")
async def handle_webhook(request: Request):
payload = await request.body()
sig = request.headers.get("stripe-signature", "")
try:
event = stripe.Webhook.construct_event(
payload, sig, os.environ["STRIPE_WEBHOOK_SECRET"]
)
except stripe.error.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
event_type = event["type"]
data = event["data"]["object"]
if event_type == "checkout.session.completed":
await handle_checkout_complete(data)
elif event_type == "customer.subscription.updated":
await handle_subscription_update(data)
elif event_type == "invoice.payment_failed":
await handle_failed_payment(data)
return {"received": True}Laravel
composer require stripe/stripe-php laravel/cashier<?php
namespace App\Services;
use Stripe\StripeClient;
class StripeService
{
private StripeClient $stripe;
public function __construct()
{
$this->stripe = new StripeClient(config('services.stripe.secret'));
}
public function createCheckoutSession(string $userId, string $email, string $priceId): string
{
$session = $this->stripe->checkout->sessions->create([
'mode' => 'subscription',
'customer_email' => $email,
'line_items' => [['price' => $priceId, 'quantity' => 1]],
'success_url' => config('app.url') . '/dashboard?checkout=success',
'cancel_url' => config('app.url') . '/pricing',
'metadata' => ['user_id' => $userId],
'subscription_data' => ['metadata' => ['user_id' => $userId]],
]);
return $session->url;
}
public function createPortalSession(string $customerId): string
{
$session = $this->stripe->billingPortal->sessions->create([
'customer' => $customerId,
'return_url' => config('app.url') . '/dashboard/billing',
]);
return $session->url;
}
}Django
pip install stripe djangoimport os, json, stripe
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
stripe.api_key = os.environ['STRIPE_SECRET_KEY']
@csrf_exempt
@require_POST
def create_checkout(request):
body = json.loads(request.body)
session = stripe.checkout.Session.create(
mode='subscription',
customer_email=body['email'],
line_items=[{'price': body['price_id'], 'quantity': 1}],
success_url=os.environ['BASE_URL'] + '/dashboard?checkout=success',
cancel_url=os.environ['BASE_URL'] + '/pricing',
metadata={'user_id': body['user_id']},
)
return JsonResponse({'url': session.url})
@csrf_exempt
def stripe_webhook(request):
try:
event = stripe.Webhook.construct_event(
request.body,
request.META.get('HTTP_STRIPE_SIGNATURE', ''),
os.environ['STRIPE_WEBHOOK_SECRET'],
)
except stripe.error.SignatureVerificationError:
return JsonResponse({'error': 'Invalid signature'}, status=400)
if event['type'] == 'checkout.session.completed':
handle_checkout_complete(event['data']['object'])
return JsonResponse({'received': True})Go
go get github.com/stripe/stripe-go/v78package handlers
import (
"encoding/json"
"net/http"
"os"
"github.com/stripe/stripe-go/v78"
"github.com/stripe/stripe-go/v78/checkout/session"
)
func CreateCheckout(w http.ResponseWriter, r *http.Request) {
stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
var body struct {
PriceID string `json:"price_id"`
Email string `json:"email"`
UserID string `json:"user_id"`
}
json.NewDecoder(r.Body).Decode(&body)
base := os.Getenv("BASE_URL")
params := &stripe.CheckoutSessionParams{
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
CustomerEmail: stripe.String(body.Email),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{Price: stripe.String(body.PriceID), Quantity: stripe.Int64(1)},
},
SuccessURL: stripe.String(base + "/dashboard?checkout=success"),
CancelURL: stripe.String(base + "/pricing"),
}
params.AddMetadata("user_id", body.UserID)
s, err := session.New(params)
if err != nil {
http.Error(w, "Stripe error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"url": s.URL})
}Spring Boot
# pom.xml: com.stripe:stripe-java:25.1.0@RestController
@RequestMapping("/api/stripe")
public class StripeController {
@PostMapping("/checkout")
public ResponseEntity<Map<String, String>> createCheckout(@RequestBody Map<String, String> body) throws StripeException {
Stripe.apiKey = System.getenv("STRIPE_SECRET_KEY");
String base = System.getenv("BASE_URL");
SessionCreateParams params = SessionCreateParams.builder()
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
.setCustomerEmail(body.get("email"))
.addLineItem(SessionCreateParams.LineItem.builder()
.setPrice(body.get("price_id")).setQuantity(1L).build())
.setSuccessUrl(base + "/dashboard?checkout=success")
.setCancelUrl(base + "/pricing")
.putMetadata("user_id", body.get("user_id"))
.build();
Session session = Session.create(params);
return ResponseEntity.ok(Map.of("url", session.getUrl()));
}
}ASP.NET Core
dotnet add package Stripe.net[ApiController]
[Route("api/stripe")]
[Authorize]
public class StripeController : ControllerBase
{
[HttpPost("checkout")]
public async Task<IActionResult> CreateCheckout([FromBody] CheckoutRequest request)
{
StripeConfiguration.ApiKey = Environment.GetEnvironmentVariable("STRIPE_SECRET_KEY");
var baseUrl = Environment.GetEnvironmentVariable("BASE_URL");
var options = new SessionCreateOptions
{
Mode = "subscription",
CustomerEmail = request.Email,
LineItems = new List<SessionLineItemOptions>
{
new SessionLineItemOptions { Price = request.PriceId, Quantity = 1 },
},
SuccessUrl = baseUrl + "/dashboard?checkout=success",
CancelUrl = baseUrl + "/pricing",
Metadata = new Dictionary<string, string> { { "user_id", request.UserId } },
};
var service = new SessionService();
var session = await service.CreateAsync(options);
return Ok(new { url = session.Url });
}
}Rust
# Cargo.toml: axum = "0.7", reqwest = { features = ["json", "form"] }, serde_json = "1"use axum::{Json, extract::State};
use reqwest::Client;
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct CheckoutRequest {
price_id: String,
email: String,
user_id: String,
}
#[derive(Serialize)]
pub struct CheckoutResponse {
url: String,
}
pub async fn create_checkout(
State(client): State<Client>,
Json(body): Json<CheckoutRequest>,
) -> Json<CheckoutResponse> {
let secret = std::env::var("STRIPE_SECRET_KEY").unwrap();
let base = std::env::var("BASE_URL").unwrap();
let res = client
.post("https://api.stripe.com/v1/checkout/sessions")
.basic_auth(&secret, Some(""))
.form(&[
("mode", "subscription"),
("customer_email", &body.email),
("line_items[0][price]", &body.price_id),
("line_items[0][quantity]", "1"),
("success_url", &format!("{base}/dashboard?checkout=success")),
("cancel_url", &format!("{base}/pricing")),
("metadata[user_id]", &body.user_id),
])
.send().await.unwrap();
let data: serde_json::Value = res.json().await.unwrap();
Json(CheckoutResponse { url: data["url"].as_str().unwrap_or("").to_string() })
}Webhooks
Webhooks are the backbone of any Stripe integration. Every significant event in Stripe, payment succeeded, subscription renewed, invoice failed, customer updated, fires a webhook event to your endpoint. Your application reacts to these events rather than polling the Stripe API for state changes.
Webhook Handler, Node.js
The raw request body must reach the webhook verification function. If your framework parses the body as JSON before verification, the signature will not match. Configure your body-parser to preserve the raw buffer for the /stripe/webhook route specifically.
import Stripe from 'stripe'
import type { Request, Response } from 'express'
import { stripe } from '../services/stripe.service'
export async function handleStripeWebhook(req: Request, res: Response) {
const sig = req.headers['stripe-signature'] as string
let event: Stripe.Event
try {
// req.body must be the raw Buffer, not parsed JSON
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return res.status(400).send('Webhook signature verification failed')
}
// Respond immediately; process asynchronously
res.status(200).json({ received: true })
try {
switch (event.type) {
case 'checkout.session.completed':
await onCheckoutComplete(event.data.object as Stripe.Checkout.Session)
break
case 'customer.subscription.created':
case 'customer.subscription.updated':
await onSubscriptionChange(event.data.object as Stripe.Subscription)
break
case 'customer.subscription.deleted':
await onSubscriptionCanceled(event.data.object as Stripe.Subscription)
break
case 'invoice.payment_succeeded':
await onInvoicePaid(event.data.object as Stripe.Invoice)
break
case 'invoice.payment_failed':
await onPaymentFailed(event.data.object as Stripe.Invoice)
break
default:
// Log unhandled events for visibility
console.log('Unhandled Stripe event:', event.type)
}
} catch (err) {
// Log processing errors, the 200 is already sent so Stripe won't retry
console.error('Webhook handler error:', event.type, err)
}
}
async function onCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId
if (!userId) return
await db.subscriptions.upsert({
where: { userId },
create: {
userId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
status: 'active',
plan: session.metadata?.plan ?? 'pro',
},
update: {
status: 'active',
stripeSubscriptionId: session.subscription as string,
},
})
}
async function onPaymentFailed(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string
await db.subscriptions.update({
where: { stripeCustomerId: customerId },
data: { status: 'past_due' },
})
// Trigger email notification
await sendPaymentFailedEmail(customerId, invoice)
}Testing Webhooks Locally
stripe listen --forward-to localhost:3000/stripe/webhookThe Stripe CLI forwards live webhook events from your Stripe account to your local server. Run the command above, copy the webhook signing secret it outputs, and set it as STRIPE_WEBHOOK_SECRET in your local .env. You can also trigger specific events manually with stripe trigger checkout.session.completed to test your handlers without completing a real checkout.
Subscription Management
Subscriptions in Stripe are living objects. They move through states, trialing, active, past_due, canceled, unpaid, and generate Invoices on each billing cycle. Your database should mirror the subscription state, updated by webhook events rather than direct API calls.
import { stripe } from './stripe.service'
// Cancel at the end of the current billing period
export async function cancelSubscription(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
})
}
// Reactivate a canceled subscription before period end
export async function reactivateSubscription(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
})
}
// Upgrade or downgrade to a different price
export async function changeSubscriptionPlan(subscriptionId: string, newPriceId: string) {
const sub = await stripe.subscriptions.retrieve(subscriptionId)
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: sub.items.data[0].id, price: newPriceId }],
proration_behavior: 'create_prorations',
})
}
// Pause collection, subscription stays active, no charges
export async function pauseSubscription(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
pause_collection: { behavior: 'void' },
})
}
// Usage-based billing: report metered usage
export async function reportUsage(subscriptionItemId: string, quantity: number) {
return stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment',
})
}Subscription States in Your Database
- active, subscription is current, payment succeeded on last attempt
- trialing, in a free trial period, no payment charged yet
- past_due, latest invoice payment failed, Stripe will retry automatically
- canceled, subscription was canceled, access should be revoked after period_end
- unpaid, all retries exhausted, subscription access should be suspended
- incomplete, subscription created but first payment not yet confirmed
Security
PCI Compliance
Stripe's hosted Checkout and Payment Elements architecture makes your integration SAQ A compliant, the lightest PCI compliance category. Your servers never touch raw card data. Stripe's servers handle card collection, storage, and transmission. If you process card data on your own servers in any way (even passing it through a proxy), your compliance scope expands significantly and requires a security audit.
Webhook Signature Verification
Every webhook event from Stripe is signed with your webhook secret using HMAC-SHA256. Call stripe.webhooks.constructEvent() with the raw request body, signature header, and your webhook secret on every incoming request. This function throws if the signature is invalid. Never process a webhook event before calling this verification step, an attacker who knows your webhook URL can fabricate events otherwise.
Secret Key Management
# Never commit these, store in a secrets manager in production
STRIPE_SECRET_KEY=sk_live_... # Server only, never expose to client
STRIPE_WEBHOOK_SECRET=whsec_... # Server only
STRIPE_PUBLISHABLE_KEY=pk_live_... # Safe for frontend, public by design
# Test mode equivalents for development
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # From: stripe listen --print-secret
STRIPE_PUBLISHABLE_KEY=pk_test_...
# Your application URLs
NEXT_PUBLIC_BASE_URL=https://yourapp.com
BASE_URL=https://yourapp.comFraud Prevention
Stripe Radar is an ML-based fraud detection system that runs on every transaction. It is enabled by default at no extra cost. Radar evaluates hundreds of signals, IP geolocation, card velocity, device fingerprint, BIN data, and blocks or flags suspicious transactions automatically. Configure Radar rules in the Stripe Dashboard to customize the thresholds for your business.
For high-risk transactions, enable 3D Secure (3DS) authentication. Setting request_three_d_secure: 'automatic' on the PaymentIntent instructs Stripe to request 3DS when Radar recommends it. 3DS shifts chargeback liability to the issuing bank, reducing your fraud exposure on authenticated transactions.
export async function createPaymentIntent(amount: number, currency: string, customerId: string) {
return stripe.paymentIntents.create({
amount, // Always in smallest currency unit (cents for USD)
currency,
customer: customerId,
payment_method_types: ['card'],
payment_method_options: {
card: {
request_three_d_secure: 'automatic',
},
},
// Idempotency key prevents double-charges on retry
// Pass this in the request options, not the params
})
}Automation
Subscription Lifecycle Automation
Every subscription lifecycle event, created, renewed, upgraded, downgraded, canceled, expired, should trigger a corresponding action in your system. Map each Stripe event to a business outcome and implement the handler. The most important ones: customer.subscription.created (provision access), customer.subscription.deleted (revoke access), invoice.payment_succeeded (record revenue), invoice.payment_failed (begin recovery flow).
Failed Payment Recovery
Stripe's Smart Retries automatically retries failed subscription payments at times statistically likely to succeed, this alone recovers 20 to 30 percent of failed payments without any code. Enable it in Stripe Dashboard > Billing > Revenue Recovery. For the payments that still fail after retries, implement an in-app dunning flow.
import type Stripe from 'stripe'
import { stripe } from '../services/stripe.service'
export async function handleFailedPayment(invoice: Stripe.Invoice) {
const customerId = invoice.customer as string
const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer
const userId = customer.metadata?.userId
if (!userId) return
// 1. Update subscription status
await db.subscriptions.update({
where: { stripeCustomerId: customerId },
data: {
status: 'past_due',
lastFailedAt: new Date(),
failureCount: { increment: 1 },
},
})
// 2. Create a portal link for self-serve recovery
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: process.env.BASE_URL + '/dashboard/billing',
})
// 3. Send recovery email
await sendEmail({
to: customer.email!,
template: 'payment_failed',
data: {
amount: (invoice.amount_due / 100).toFixed(2),
currency: invoice.currency.toUpperCase(),
retryDate: invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString()
: null,
portalUrl: portalSession.url,
},
})
}Customer Onboarding After Checkout
import type Stripe from 'stripe'
export async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId
if (!userId) return
// 1. Retrieve the full subscription to get the plan details
const subscription = await stripe.subscriptions.retrieve(session.subscription as string)
const priceId = subscription.items.data[0].price.id
// 2. Provision access based on price ID
const plan = getPlanFromPriceId(priceId)
await db.users.update({
where: { id: userId },
data: {
plan,
planActivatedAt: new Date(),
stripeCustomerId: session.customer as string,
},
})
// 3. Create subscription record
await db.subscriptions.create({
data: {
userId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
stripePriceId: priceId,
status: 'active',
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
})
// 4. Send welcome email and trigger onboarding sequence
await sendWelcomeEmail(userId, plan)
await triggerOnboardingSequence(userId)
}Invoice Automation
Stripe automatically generates and sends invoices for subscription renewals. Configure this in the Stripe Dashboard under Billing > Invoice settings, set the invoice footer, logo, tax ID display, and whether to automatically finalize and send invoices. For custom invoicing (B2B with purchase orders, Net 30 terms, etc.), use the Stripe Invoices API to create manual invoices with custom line items, due dates, and automatic reminders.
Common Mistakes
Trusting the Success URL to Confirm Payment
The success_url fires when Stripe redirects the user after checkout, not when payment is confirmed. If the user closes the browser during redirect, or if the URL is modified, the success page fires but the payment may not have completed. Always fulfill orders from the checkout.session.completed webhook event, never from the success URL. Use the success URL only to show a friendly confirmation message.
Passing Parsed JSON to Webhook Verification
stripe.webhooks.constructEvent() requires the raw request body as a Buffer or string, exactly as received from Stripe. Most web frameworks parse the body as JSON by default, which transforms the raw bytes. This causes the HMAC signature to not match and every webhook to fail verification. For Express, configure body-parser to preserve raw bytes specifically for the /stripe/webhook route using the verify callback. For Next.js app router, disable body parsing by exporting config from the route file.
Incorrect Subscription Cancellation Logic
When a user cancels, set cancel_at_period_end: true rather than immediately deleting the subscription. This lets the user keep access until the end of their paid period, which is the correct behavior and what customers expect. Listen for the customer.subscription.deleted webhook (which fires at period end) to actually revoke access. Revoking access immediately on cancel is a common source of support tickets and chargebacks.
Not Using Idempotency Keys
Network errors can cause your backend to retry a Stripe API call that already succeeded on the first attempt. Without idempotency keys, this can create duplicate charges, duplicate customers, or duplicate subscriptions. Pass an idempotency key for every payment-creating API call, a string that is unique to the specific operation. Stripe deduplicates requests with the same key within a 24-hour window.
Hardcoding Price IDs or Amount Values
Never hardcode price IDs or currency amounts in frontend code. Price IDs are environment-specific (test vs live) and change when you update pricing. Amounts should always originate from your backend and be verified server-side. Fetch available plans from an API endpoint that reads from your database or environment variables, do not embed them directly in the client bundle.
Production Deployment Checklist
Before processing live payments, verify every item on this list.
- ✓ STRIPE_SECRET_KEY is a live key (sk_live_...) stored in a secrets manager, not in code
- ✓ STRIPE_PUBLISHABLE_KEY is a live key (pk_live_...), safe to expose in frontend
- ✓ STRIPE_WEBHOOK_SECRET is set from the live webhook endpoint, not the CLI test secret
- ✓ Webhook endpoint registered in Stripe Dashboard with HTTPS URL and correct event subscriptions
- ✓ Webhook signature verified using stripe.webhooks.constructEvent() on every request
- ✓ Raw request body preserved for webhook route (body-parser rawBody, or equivalent)
- ✓ Success URL only shows a confirmation message, fulfillment is handled in webhook handler
- ✓ checkout.session.completed handler tested with stripe trigger checkout.session.completed
- ✓ invoice.payment_failed handler implemented and sends recovery email with portal link
- ✓ customer.subscription.deleted handler revokes access correctly
- ✓ Stripe Customer ID stored per user in the database (never create duplicate customers)
- ✓ Stripe Customer Portal enabled in Dashboard and linked from billing settings page
- ✓ Smart Retries enabled in Dashboard under Billing > Revenue Recovery
- ✓ Stripe Radar enabled (it is by default, confirm it has not been disabled)
- ✓ 3D Secure set to automatic on PaymentIntents for fraud liability shift
- ✓ Idempotency keys used for all payment-creating API calls
- ✓ HTTPS enforced on all endpoints (Stripe requires HTTPS for live mode webhooks)
- ✓ Subscription status synced from webhooks to database, not from API polling
- ✓ All Stripe API calls wrapped in try/catch with user-friendly error messages
- ✓ Full end-to-end test completed in live mode with a real card before launch