Building Stripe Integration from Scratch vs Using a Boilerplate (2026)
TL;DR
Stripe integration from scratch typically takes 6-10 days when done correctly. Boilerplates compress this to configuration + testing (1-2 days). The difference isn't just time — boilerplates handle the subscription lifecycle edge cases (dunning, proration, cancellations) that are invisible until a real customer hits them. For most SaaS, use a boilerplate's Stripe integration.
Key Takeaways
- Basic Stripe checkout: 2-3 days
- Subscription webhooks (all events): 3-4 days
- Customer portal: 1-2 days
- Failed payment handling: 2-3 days
- Plan upgrades/downgrades: 2-3 days
- Usage-based billing: 3-5 additional days
The Stripe Event Surface Area
The biggest mistake in Stripe integrations: only handling checkout.session.completed. The full required surface:
// The events you MUST handle for a production subscription product
const REQUIRED_EVENTS = [
'checkout.session.completed', // New subscription created
'customer.subscription.updated', // Plan changed, trial end, etc.
'customer.subscription.deleted', // Subscription canceled
'invoice.payment_succeeded', // Successful recurring payment
'invoice.payment_failed', // Failed payment — trigger dunning
'invoice.upcoming', // Upcoming payment notification
'customer.updated', // Customer email/details changed
] as const;
// Events you probably need for edge cases
const IMPORTANT_EDGE_CASES = [
'customer.subscription.trial_will_end', // 3 days before trial ends
'payment_intent.payment_failed', // Immediate payment failure
'charge.dispute.created', // Chargeback opened
'invoice.finalized', // Invoice is ready
] as const;
Most from-scratch implementations only handle the first 2-3 events. The gaps show up as:
- Users who cancel but retain access (missing
subscription.deleted) - Users who pay but lose access after failed invoice (missing state management)
- Revenue leaking from unhandled trial conversions
The Correct Webhook Handler
// Production webhook handler — every piece matters
export async function POST(req: Request) {
const body = await req.text(); // text, not json — for signature verification
const sig = req.headers.get('stripe-signature');
if (!sig) return new Response('Missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body, // Raw body (not parsed)
sig, // Signature header
process.env.STRIPE_WEBHOOK_SECRET! // Endpoint-specific secret
);
} catch (err) {
// Don't reveal error details — security
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
// Idempotency: webhooks can be delivered multiple times
const processed = await redis.get(`stripe:webhook:${event.id}`);
if (processed) {
return new Response('Already processed', { status: 200 });
}
try {
await handleStripeEvent(event);
await redis.set(`stripe:webhook:${event.id}`, '1', { ex: 24 * 60 * 60 }); // 24h TTL
} catch (err) {
// Don't return 2xx on failure — Stripe will retry
console.error('Webhook processing failed:', err);
return new Response('Processing failed', { status: 500 });
}
return new Response('OK', { status: 200 });
}
The idempotency pattern is critical — Stripe retries webhooks on 5xx responses. Without it, a temporary database error causes double-processing.
Subscription State Machine
Subscriptions have complex state transitions that must be handled correctly:
new → trialing → active → past_due → canceled
↓
incomplete → incomplete_expired
↓
paused
// Correct subscription state handling
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const status = subscription.status;
const priceId = subscription.items.data[0]?.price.id;
const accessMap = {
active: true,
trialing: true, // Trials have access
past_due: true, // Grace period — keep access while retrying payment
canceled: false,
incomplete: false,
incomplete_expired: false,
paused: false,
unpaid: false,
} as const;
const hasAccess = accessMap[status] ?? false;
const planId = getPlanFromPriceId(priceId);
await db.subscription.upsert({
where: { stripeSubscriptionId: subscription.id },
update: {
status,
hasAccess,
planId,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
create: {
stripeSubscriptionId: subscription.id,
userId: await getUserIdFromCustomer(subscription.customer as string),
status,
hasAccess,
planId,
},
});
}
This state machine handles all the cases. A simple if (status === 'active') check misses trialing, past_due grace periods, and cancellation scheduling.
Dunning: The Revenue Saver
Failed payment recovery (dunning) is invisible until you're losing money:
// What happens when a payment fails
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const customer = await stripe.customers.retrieve(invoice.customer as string);
const user = await getUserByStripeCustomerId(customer.id);
// How many times has payment failed?
const attemptCount = invoice.attempt_count;
if (attemptCount === 1) {
// First failure: send friendly reminder
await sendEmail({
to: user.email,
template: 'payment-failed-first',
data: { retryDate: getNextRetryDate(invoice) }
});
} else if (attemptCount === 2) {
// Second failure: more urgent email
await sendEmail({
to: user.email,
template: 'payment-failed-urgent',
data: { updatePaymentUrl: getCustomerPortalUrl(user) }
});
} else if (attemptCount >= 3) {
// Final failure: subscription will be canceled
await sendEmail({
to: user.email,
template: 'payment-failed-final',
data: { cancelDate: getCancelDate(invoice) }
});
}
// Log for revenue recovery analysis
await createEvent({ type: 'payment_failed', userId: user.id, attemptCount });
}
Stripe's Smart Retries recovers 16% of initially failed payments. Your dunning emails recover another 5-10%. Combined: you keep 20%+ of revenue that would otherwise be lost.
Plan Upgrades/Downgrades
Proration is conceptually simple but easily wrong:
// Correct upgrade handling with Stripe proration
async function upgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItemId = subscription.items.data[0].id;
return stripe.subscriptions.update(subscriptionId, {
items: [{
id: currentItemId,
price: newPriceId,
}],
proration_behavior: 'create_prorations',
// 'create_prorations': Immediate proration on next invoice
// 'always_invoice': Immediate invoice for the difference
// 'none': No proration (user gets charged full amount)
billing_cycle_anchor: 'unchanged', // Keep billing date consistent
});
}
// IMPORTANT: Also handle access update immediately, not just at next invoice
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const newPlan = getPlanFromPriceId(subscription.items.data[0].price.id);
await grantPlanAccess(subscription.customer as string, newPlan);
}
Forgetting billing_cycle_anchor: 'unchanged' resets the billing cycle on upgrade, causing immediate unexpected charges.
What Boilerplates Handle For You
| Scenario | From Scratch (days) | Boilerplate |
|---|---|---|
| Basic checkout | 2 | Configured |
| All webhook events | 4 | Handled |
| Subscription states | 2 | Handled |
| Dunning emails | 2 | Handled |
| Customer portal | 1 | Configured |
| Plan upgrades | 2 | Handled |
| Trial-to-paid | 1 | Handled |
| Total | 14 days | 1-2 days config |
When to Build Stripe From Scratch
- Non-standard pricing: Complex usage-based billing with custom calculations
- Marketplace/connect: Stripe Connect for platform billing requires custom implementation
- Multiple payment processors: If you need Stripe + PayPal + ACH, custom integration
- Existing billing system: Migrating from another processor to Stripe
For standard subscription SaaS (fixed monthly/annual plans), boilerplate Stripe integration covers everything.
Find boilerplates with the most complete Stripe implementations on StarterPick.
Check out this boilerplate
View ShipFast on StarterPick →