Skip to main content

Stripe from Scratch vs Boilerplate 2026

·StarterPick Team
Share:

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.

What Boilerplates Actually Ship

The difference between boilerplates in their Stripe support comes down to what is pre-wired versus what is documented but not implemented.

ShipFast ships a complete production webhook handler covering all seven required events, a customer portal redirect route, and checkout session creation with proper metadata. The Stripe webhook handling follows the correct req.text() pattern (not req.json()), which is the most common mistake in from-scratch implementations. ShipFast also includes Lemon Squeezy as an alternative processor — the only boilerplate that ships both as drop-in options.

OpenSaaS ships Stripe and the webhook handler pre-configured through Wasp's built-in job system. Payment failure events trigger background jobs for dunning emails, which is the architecturally correct pattern — dunning should happen asynchronously, not in the webhook request. OpenSaaS also ships the subscription access check as a middleware utility that can be applied to any protected route.

Makerkit ships a Stripe billing schema that includes billingProvider, planId, priceId, and subscription status fields with proper TypeScript types. The schema design reflects years of iteration — it handles multiple items per subscription, which simpler schemas often miss. This matters for add-ons or seat-based billing.

T3 Stack ships nothing for Stripe. You add it yourself, using the pattern documented above. This is the correct expectation — T3 is a foundation, not a product.

The practical test for a boilerplate's Stripe quality: find its webhook handler and count how many events it explicitly handles. Fewer than six events means incomplete coverage.

Testing Stripe Webhooks Locally

Local webhook testing is where many from-scratch implementations develop their first gaps. Stripe provides the CLI tool for forwarding webhook events to your local server.

# Install Stripe CLI, then forward webhooks:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger a specific event to test handling:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed

The stripe trigger command is essential for testing edge cases before production. A correct from-scratch implementation should explicitly test each event in the REQUIRED_EVENTS list before launch. Boilerplates have typically already tested these paths — the webhook handlers exist precisely because the edge cases were discovered during development. If you're launching quickly with a boilerplate, the boilerplate to launch in 7 days guide includes a production checklist that covers Stripe live mode activation timing.

One specific pattern to test: trigger invoice.payment_failed and verify that the user retains access (past_due grace period) rather than immediately losing it. Then trigger customer.subscription.deleted and verify access is revoked. These two behaviors together validate the most common production Stripe bugs.

Stripe vs LemonSqueezy for Simple SaaS

The Stripe vs LemonSqueezy decision deserves a brief note because it changes which boilerplates are viable options.

LemonSqueezy is a Merchant of Record — it handles VAT and sales tax collection globally, so you don't need to register for GST in Australia or VAT in Europe. For a solo founder selling to international customers, this is worth the higher fee (LemonSqueezy takes ~5% compared to Stripe's 2.9%). ShipFast ships both Stripe and LemonSqueezy integrations; most other boilerplates ship only Stripe.

The subscription lifecycle events from LemonSqueezy differ from Stripe's. The webhook payload shapes differ, and LemonSqueezy's subscription states don't map one-to-one with Stripe's status values. This means a ShipFast LemonSqueezy integration is a genuinely separate implementation, not a wrapper around the Stripe logic.

For B2B SaaS primarily selling to US companies, Stripe is standard. For consumer SaaS or developer tools with a global audience that includes individual customers in VAT-territory countries, LemonSqueezy's Merchant of Record status saves significant compliance overhead. The boilerplate you choose should match this decision — verifying that the Stripe integration is complete is the primary filter.

International Payments and Currency

Stripe handles multi-currency but requires explicit configuration. Most boilerplate Stripe integrations default to USD. Adding multi-currency support requires Stripe's presentment currency feature and price lookup by currency in checkout session creation.

Teams launching to international markets should verify their boilerplate creates checkout sessions with currency derived from the customer's location and presents local currency amounts. The customer experience difference between "USD 49.00" and "EUR 45.00" is meaningful for conversion on non-US traffic.

This is typically custom work on top of any boilerplate. The boilerplate handles the event lifecycle; currency localization is a product decision that requires implementation on top.

The Real Cost of From-Scratch Stripe

Building Stripe from scratch for a SaaS product isn't just 6-10 days of work upfront — it's ongoing maintenance. Stripe releases API version updates, occasionally changes webhook payload shapes, and deprecates older integration patterns. A boilerplate vendor (or the open-source community behind a free boilerplate) absorbs that maintenance work and ships updates. A custom from-scratch implementation is your responsibility indefinitely.

The specific maintenance surface area includes: keeping up with Stripe's API versioning policy, updating the webhook endpoint when Stripe's event schemas evolve, and handling new payment methods (Apple Pay, Google Pay, bank redirect networks) that Stripe adds to its checkout. Boilerplates that use Stripe's Checkout product (redirecting to Stripe's hosted checkout page) insulate you from most of this — Stripe's own UI handles the payment method rendering.

Teams building custom embedded checkout UI (using Stripe Elements directly) take on the largest maintenance burden. Custom Elements integrations require explicit handling of each payment method, 3D Secure authentication flows, and various error states. A boilerplate that ships Stripe Checkout redirect avoids all of this. If your product can tolerate the Stripe-hosted checkout UX (most SaaS products can), use it.

How Stripe Billing Affects SaaS Boilerplate Choice

The billing complexity your product requires is one of the most reliable filters for boilerplate selection. If your product has simple monthly/annual subscriptions with a fixed price, every major boilerplate's Stripe integration is sufficient. If your product has usage-based components, add-ons, or seat-based pricing, fewer boilerplates handle this cleanly.

For products that will eventually need usage-based billing — where you track API calls, storage, or active seats and bill accordingly at period end — plan for custom implementation on top of any boilerplate's base Stripe setup. The metering API and usage records are not pre-configured in current boilerplates, and getting them right requires understanding Stripe's billing model in depth. The best SaaS boilerplates for 2026 notes which boilerplates have moved toward supporting usage-based patterns.

For more context on the full boilerplate landscape, see StarterPick's boilerplate comparison page. The ShipFast review covers its Stripe implementation in detail, including the webhook handler, customer portal, and LemonSqueezy alternative. Stripe's API has continued evolving in 2025 with new payment method defaults and updated billing portal APIs, which reinforces why inheriting a maintained boilerplate's Stripe setup is often preferable to building and maintaining a custom integration independently.

Find boilerplates with the most complete Stripe implementations on StarterPick.

Review ShipFast and compare alternatives on StarterPick.

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.