Skip to main content

Building Stripe Integration from Scratch vs Using a Boilerplate (2026)

·StarterPick Team
stripebillingboilerplateintegration2026

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

ScenarioFrom Scratch (days)Boilerplate
Basic checkout2Configured
All webhook events4Handled
Subscription states2Handled
Dunning emails2Handled
Customer portal1Configured
Plan upgrades2Handled
Trial-to-paid1Handled
Total14 days1-2 days config

When to Build Stripe From Scratch

  1. Non-standard pricing: Complex usage-based billing with custom calculations
  2. Marketplace/connect: Stripe Connect for platform billing requires custom implementation
  3. Multiple payment processors: If you need Stripe + PayPal + ACH, custom integration
  4. 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 →

Comments