Stripe Billing Implementation: Subscriptions, Invoices, and Webhooks
Stripe is the de facto standard for billing infrastructure, but implementing it correctly — handling webhooks idempotently, managing subscription state transitions, and recovering from failed payments — takes considerably more care than the getting-started documentation suggests. A payment system that works 99% of the time and fails silently the other 1% is worse than one that fails loudly at 95%.
This guide covers a production Stripe implementation with 6 webhook event types, subscription lifecycle management, automatic license provisioning, and refund handling. The code examples use NestJS 11, but the patterns apply to any backend framework.
Key Takeaways
- Never provision access based on checkout redirect — always wait for the
checkout.session.completedwebhook- Verify webhook signatures with
stripe.webhooks.constructEvent()before processing any event- Make all webhook handlers idempotent — Stripe retries failed webhooks for up to 72 hours
- Store Stripe customer IDs, subscription IDs, and price IDs in your database — don't re-query Stripe for everything
- Handle
invoice.payment_failedto trigger dunning emails and subscription suspensioncharge.refundedandcheckout.session.completedtogether cover the full purchase lifecycle- Use Stripe's
metadatafield to link Stripe objects to your internal records- Never log raw Stripe webhook payloads — they contain sensitive card data
Project Setup
pnpm add stripe
// billing/stripe.client.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
Store these environment variables:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
Checkout Session Creation
The checkout flow: your backend creates a Stripe Checkout session, the frontend redirects to Stripe, Stripe collects payment, then calls your webhook. Never handle payment yourself — always redirect to Stripe Checkout.
// billing/billing.service.ts
import Stripe from 'stripe';
import { Injectable, BadRequestException } from '@nestjs/common';
import { stripe } from './stripe.client';
@Injectable()
export class BillingService {
async createCheckoutSession(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
// Get or create Stripe customer
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'payment', // or 'subscription' for recurring
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/services`,
metadata: {
userId,
organizationId: orgId,
productId,
},
allow_promotion_codes: true,
invoice_creation: {
enabled: true,
},
});
return { url: session.url };
}
async createSubscriptionCheckout(
userId: string,
orgId: string,
priceId: string,
productId: string
) {
const customer = await this.getOrCreateCustomer(userId, orgId);
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
subscription_data: {
metadata: {
userId,
organizationId: orgId,
productId,
},
trial_period_days: 14,
},
success_url: `${process.env.FRONTEND_URL}/dashboard/billing?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/pricing`,
metadata: {
userId,
organizationId: orgId,
productId,
},
});
return { url: session.url };
}
private async getOrCreateCustomer(userId: string, orgId: string) {
// Check if customer already exists in your DB
const existing = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (existing) return existing;
// Fetch user details for Stripe customer creation
const user = await db.query.users.findFirst({
where: eq(users.id, userId),
});
const stripeCustomer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId,
organizationId: orgId,
},
});
const [customer] = await db
.insert(billingCustomers)
.values({
userId,
organizationId: orgId,
stripeCustomerId: stripeCustomer.id,
})
.returning();
return customer;
}
}
Webhook Handler
The webhook handler is the most critical part of the Stripe integration. Every event must be idempotent:
// billing/webhook.controller.ts
import {
Controller,
Post,
Req,
Headers,
RawBodyRequest,
HttpCode,
} from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
import { stripe } from './stripe.client';
import { Public } from '../auth/decorators/public.decorator';
import { BillingWebhookService } from './billing-webhook.service';
@Controller('billing/webhook')
export class WebhookController {
constructor(private webhookService: BillingWebhookService) {}
@Post()
@Public()
@HttpCode(200) // Always return 200 — Stripe retries on non-2xx
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string
) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody!, // Requires raw body middleware
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Invalid signature — do not process
throw new Error(`Webhook signature verification failed: ${err.message}`);
}
// Route to specific handler
switch (event.type) {
case 'checkout.session.completed':
await this.webhookService.handleCheckoutCompleted(
event.data.object as Stripe.Checkout.Session
);
break;
case 'invoice.paid':
await this.webhookService.handleInvoicePaid(
event.data.object as Stripe.Invoice
);
break;
case 'invoice.payment_failed':
await this.webhookService.handlePaymentFailed(
event.data.object as Stripe.Invoice
);
break;
case 'customer.subscription.deleted':
await this.webhookService.handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
break;
case 'customer.subscription.updated':
await this.webhookService.handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
break;
case 'charge.refunded':
await this.webhookService.handleChargeRefunded(
event.data.object as Stripe.Charge
);
break;
default:
// Ignore unhandled event types
break;
}
return { received: true };
}
}
Important: NestJS requires rawBody: true in NestFactory.create() for Stripe signature verification:
// main.ts
const app = await NestFactory.create(AppModule, {
rawBody: true,
});
Checkout Completed Handler
This is the core provisioning handler. Make it idempotent:
// billing/billing-webhook.service.ts
@Injectable()
export class BillingWebhookService {
async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
// Check if already processed (idempotency)
const existing = await db.query.orders.findFirst({
where: eq(orders.stripeSessionId, session.id),
});
if (existing) {
return; // Already processed — Stripe is retrying
}
const { userId, organizationId, productId } = session.metadata!;
// Create order in transaction
await db.transaction(async (tx) => {
// Create order
const [order] = await tx
.insert(orders)
.values({
userId,
organizationId,
productId,
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string,
amount: session.amount_total! / 100, // Stripe uses cents
currency: session.currency!,
status: 'completed',
})
.returning();
// Generate license key
const licenseKey = generateLicenseKey(productId);
// Create license
await tx.insert(licenses).values({
orderId: order.id,
userId,
organizationId,
productId,
licenseKey,
status: 'active',
issuedAt: new Date(),
});
});
// Send confirmation email (non-blocking — don't fail the webhook)
this.emailService
.sendPurchaseConfirmation(userId, productId)
.catch((err) => this.logger.error('Email send failed', err));
}
async handleInvoicePaid(invoice: Stripe.Invoice) {
// For subscriptions — renewal
if (!invoice.subscription) return;
// Find existing subscription/license and extend it
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
await db
.update(licenses)
.set({
status: 'active',
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // +30 days
})
.where(eq(licenses.id, license.id));
// Log renewal in audit table
await db.insert(licenseAuditLogs).values({
licenseId: license.id,
action: 'renewed',
details: { invoiceId: invoice.id },
});
}
async handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, invoice.subscription as string),
});
if (!license) return;
// Grace period before suspension (e.g., 3 days)
const gracePeriod = new Date();
gracePeriod.setDate(gracePeriod.getDate() + 3);
if (new Date() > gracePeriod) {
await db
.update(licenses)
.set({ status: 'suspended' })
.where(eq(licenses.id, license.id));
}
// Send dunning email
this.emailService
.sendPaymentFailedNotification(license.userId, invoice)
.catch(console.error);
}
async handleChargeRefunded(charge: Stripe.Charge) {
const order = await db.query.orders.findFirst({
where: eq(orders.stripePaymentIntentId, charge.payment_intent as string),
});
if (!order) return;
await db.transaction(async (tx) => {
// Update order status
await tx
.update(orders)
.set({ status: 'refunded' })
.where(eq(orders.id, order.id));
// Revoke associated licenses
await tx
.update(licenses)
.set({ status: 'revoked', revokedAt: new Date() })
.where(eq(licenses.orderId, order.id));
});
// Log audit trail
await db.insert(licenseAuditLogs).values({
licenseId: (await db.query.licenses.findFirst({
where: eq(licenses.orderId, order.id),
}))!.id,
action: 'revoked',
details: { reason: 'refund', chargeId: charge.id },
});
}
async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db
.update(licenses)
.set({ status: 'cancelled', cancelledAt: new Date() })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const status = subscription.status === 'active' ? 'active' : 'suspended';
await db
.update(licenses)
.set({ status })
.where(eq(licenses.stripeSubscriptionId, subscription.id));
}
}
Customer Portal
Allow customers to manage their own subscriptions without contacting support:
async createPortalSession(userId: string, orgId: string) {
const customer = await db.query.billingCustomers.findFirst({
where: and(
eq(billingCustomers.userId, userId),
eq(billingCustomers.organizationId, orgId)
),
});
if (!customer) {
throw new NotFoundException('No billing account found');
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.FRONTEND_URL}/dashboard/billing`,
});
return { url: portalSession.url };
}
The customer portal lets users cancel subscriptions, update payment methods, download invoices, and view billing history — without any custom UI work.
Frontend Integration
// web: lib/billing.ts
'use client';
import { apiFetch } from '@/lib/api';
export async function initiateCheckout(priceId: string, productId: string) {
const { url } = await apiFetch<{ url: string }>('/billing/checkout', {
method: 'POST',
body: JSON.stringify({ priceId, productId }),
});
// Redirect to Stripe Checkout
window.location.href = url;
}
export async function openCustomerPortal() {
const { url } = await apiFetch<{ url: string }>('/billing/portal', {
method: 'POST',
});
window.open(url, '_blank');
}
Testing Webhooks Locally
The Stripe CLI is essential for local webhook development:
# Install Stripe CLI
npm install -g stripe
# Listen and forward webhooks to localhost
stripe listen --forward-to localhost:3001/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger charge.refunded
stripe trigger customer.subscription.deleted
# View event logs
stripe events list
The CLI provides your local webhook secret — use it in your .env.local:
STRIPE_WEBHOOK_SECRET=whsec_test_... # From `stripe listen` output
Common Pitfalls and Solutions
Pitfall 1: Provisioning access on checkout redirect instead of webhook
The checkout success redirect can be triggered by: browser back/forward, duplicate tabs, or network retry. Always provision based on the checkout.session.completed webhook, not the redirect URL.
Pitfall 2: Not handling webhook retries (idempotency)
Stripe retries failed webhooks for 72 hours. Without idempotency checks, retries create duplicate orders, licenses, and emails. Store the Stripe session ID in your orders table with a unique constraint — duplicate inserts fail gracefully.
Pitfall 3: Using raw body middleware incorrectly
Stripe signature verification requires the raw, unparsed request body. If Express's JSON body parser runs first, the raw body is gone. Use rawBody: true in NestJS and access req.rawBody, or configure the JSON middleware to preserve the raw body for the webhook route specifically.
Pitfall 4: Missing retry logic for Stripe API calls
Stripe API calls can fail transiently. Add retry logic for idempotent operations:
import Stripe from 'stripe';
async function retryStripeOperation<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (err) {
if (err instanceof Stripe.errors.StripeConnectionError && attempt < maxRetries) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
continue;
}
throw err;
}
}
throw new Error('Max retries exceeded');
}
Frequently Asked Questions
Should I use Stripe Checkout or custom payment forms?
Always use Stripe Checkout unless you have specific requirements that make it impossible (rare). Checkout handles PCI compliance, 3D Secure authentication, Apple Pay, Google Pay, SEPA, and dozens of other payment methods automatically. Custom payment forms (using Stripe Elements) require you to maintain PCI compliance yourself and implement each payment method manually. For 95% of SaaS products, Checkout is the right choice.
How do I handle Stripe's test mode vs. live mode?
Use separate environment configurations: STRIPE_SECRET_KEY=sk_test_... in development and STRIPE_SECRET_KEY=sk_live_... in production. Stripe test mode is completely isolated from live mode — no test charges affect real money and no live data appears in test mode. Use stripe.mode in your code to verify you're using the correct key, and add a prominent indicator in your admin UI showing which mode you're in.
What happens if my webhook server is down and Stripe can't deliver?
Stripe retries failed webhooks on an exponential backoff schedule for up to 72 hours: 5 minutes, 30 minutes, 1 hour, 2 hours, and increasing intervals. After 72 hours, the event is considered failed. Use Stripe's Event dashboard to manually redeliver failed events, or query stripe.events.list() and replay them. Design your handlers to be idempotent so redelivery is safe.
How do I implement metered/usage-based billing?
Stripe Billing supports metered usage via stripe.subscriptionItems.createUsageRecord(). Report usage at the end of each billing period with the quantity consumed. Price metered plans in your Stripe dashboard as "metered" pricing. The key implementation detail: report usage before the billing period ends, not after — Stripe finalizes the invoice at period end.
How should I store Stripe IDs in my database?
Store all Stripe IDs as text columns (not numeric). Create indexes on stripe_customer_id, stripe_subscription_id, and stripe_payment_intent_id — you'll look up records by these frequently. Add unique constraints where appropriate (one customer record per user). Never store credit card numbers or raw payment data in your own database.
Next Steps
Implementing Stripe billing correctly — with webhook idempotency, subscription lifecycle management, automatic provisioning, and proper refund handling — is a non-trivial engineering undertaking. ECOSIRE handles 6 Stripe webhook event types in production with automatic license provisioning and customer portal integration.
If you need Stripe integration, subscription billing infrastructure, or full-stack SaaS development support, explore our development services.
Written by
ECOSIRE Research and Development Team
Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.
Related Articles
ERP for Law Firms: Case Management, Billing, and Client Portal
Complete guide to ERP for law firms — case management, legal billing, trust accounting, IOLTA compliance, client portal, and matter management for 2026.
Media Company ERP Implementation: Editorial, Ad Sales, and Subscriptions
Step-by-step guide to implementing ERP in media companies, covering editorial workflow integration, advertising system migration, subscription billing setup, and data migration.
Professional Services ERP Implementation: Time, Billing, and Projects
Step-by-step professional services ERP implementation guide covering time tracking, billing configuration, project accounting, and resource management setup.