Introduction
Stripe is an online payment platform for securely accepting credit cards, bank transfers, and more.
This guide demonstrates an end-to-end integration using Stripe Checkout Sessions for one-time payments and a tamper-proof server webhook to confirm transactions.
Terminology: "Payment Link" here refers to the URL returned by a Stripe Checkout Session (not Stripe's no-code "Payment Links" product).
Payment Flow Overview
- Client clicks "Pay Now".
- Backend creates a unique Checkout Session and returns the session URL.
- Client is redirected to Stripe, completes payment, and is redirected to your success URL.
- Stripe sends an asynchronous webhook (server-to-server) to your backend.
- Backend verifies the webhook signature, updates the database, and fulfills the order.
- Client gains access to the purchased resource.
Textual Diagram (ASCII)
Client (browser)
|
| Click "Pay Now"
v
Backend (your server)
- create Checkout Session -> returns session.url
|
| Redirect to Stripe Checkout
v
Stripe Checkout (Hosted)
- collects payment
| \
| Redirect to success_url POST webhook -> /api/webhook
v /
Client (success_url) /
v
Backend Webhook Handler <-- verifies signature, idempotency, updates DB
|
v
Fulfillment / Grant access to client
Quick Setup
1. Install packages
npm install stripe dotenv
2. .env (example)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CLIENT_URL=http://localhost:5173
3. Initialize Stripe
// src/utils/stripe.ts
import Stripe from 'stripe';
import 'dotenv/config';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string,);
export default stripe;
Create Checkout Session (Backend Route)
Create a route that makes a Checkout Session and returns session.url:
// routes/payment.ts
import { Request, Response } from 'express';
import stripe from '../utils/stripe';
export const createCheckoutSession = async (req: Request, res: Response) => {
const { id } = req.body;
const existingProduct = { id, price: 4000, name: "Hamza's Coffee" };
try {
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card', 'link'],
line_items: [{
price_data: {
currency: 'usd',
product_data: { name: existingProduct.name, description: 'Payment for services rendered' },
unit_amount: existingProduct.price,
},
quantity: 1,
}],
success_url: `${process.env.CLIENT_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.CLIENT_URL}/payment/cancel`,
metadata: { product_id: existingProduct.id }
});
res.status(201).json({ message: 'Payment Initialized Successfully', data: { url: session.url } });
} catch (error) {
res.status(500).json({ message: 'Error initializing payment.' });
}
};
Webhook: Raw Body & Signature Verification
Define the webhook route before express.json() so the raw body can be used for signature verification.
Server Example
// server.ts
import express from 'express';
import bodyParser from 'body-parser';
import { webhookHandler } from './routes/webhook';
import { createCheckoutSession } from './routes/payment';
const app = express();
// Raw parser for webhook route only
app.post('/api/webhook', bodyParser.raw({ type: 'application/json' }), webhookHandler);
// Then JSON parser for all other routes
app.use(express.json());
app.post('/api/create-payment-link', createCheckoutSession);
app.listen(4000, () => console.log('Server running on port 4000'));
Webhook Handler
// routes/webhook.ts
import { Request, Response } from 'express';
import Stripe from 'stripe';
import stripe from '../utils/stripe';
import dotenv from 'dotenv';
dotenv.config();
export const webhookHandler = async (req: Request, res: Response) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const productId = session.metadata?.product_id;
if (productId && session.payment_status === 'paid') {
// IDEMPOTENCY: ensure session.id hasn't been processed already
// Example: if (!db.processed(session.id)) { db.markProcessed(session.id); grantAccess(productId); }
console.log(`Payment confirmed for product ID: ${productId}`);
}
}
res.status(200).json({ received: true });
};
Local Testing with Stripe CLI
- Forward events:
stripe listen --forward-to localhost:4000/api/webhook
- Copy the
whsec_...key from the CLI output into.envasSTRIPE_WEBHOOK_SECRET. - Trigger event:
stripe trigger checkout.session.completed
Deployment Checklist
- Deploy backend to production.
- Register your live webhook URL in Stripe Dashboard (Developers → Webhooks) and subscribe to
checkout.session.completed. - Set
STRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRETin your production environment. - Ensure idempotency checks on webhook processing.
Rationale / Key Decisions
- Raw body required so Stripe signature can be verified.
- Signature verification prevents forged events.
- Idempotency avoids duplicate fulfillment on retries.
- Stripe CLI simplifies local testing.
Further Reading
- Refunds: Stripe Refunds
- Subscriptions: Stripe Recurring Payments
- API Reference: Stripe API
Top comments (0)