SvelteKit + Stripe: What Every Starter Gets Wrong

Most SvelteKit starters handle Stripe checkout and stop there. Here's what they miss — webhooks, subscription lifecycle, error states, testing, and the Customer Portal.

Every SaaS starter kit has a Stripe integration. Most of them handle exactly one thing: redirecting a user to Checkout and showing a success page. That’s maybe 20% of what a production Stripe integration actually requires.

Here’s what the other 80% looks like, why it matters, and which starters in our directory actually handle it.

The Happy Path Problem

The typical starter gives you this flow:

  1. User clicks “Subscribe”
  2. Server creates a Checkout Session
  3. User completes payment on Stripe’s hosted page
  4. Redirect to /success

This works in a demo. It breaks in production because:

  • What happens when the user’s card gets declined on renewal?
  • What happens when they cancel mid-cycle?
  • What happens when Stripe sends you a customer.subscription.past_due event at 3am?
  • What happens when your webhook endpoint goes down for an hour?

If your starter doesn’t have answers to these questions, it’s a demo, not a product.

The svelte-stripe Library

Before diving into architecture: the svelte-stripe library (by joshnuss, now Stripe-sponsored) is the standard for client-side Stripe Elements in Svelte. Version 2.0 shipped recently and supports the full range of payment methods — PaymentElement, Express Checkout, embedded checkout, Link, Apple Pay, Google Pay, SEPA, iDEAL, Klarna, and more.

It’s 100% SvelteKit compatible and well-maintained. Use it for any client-side payment UI. But understand that the client-side component is the easy part. The hard part lives on the server.

Webhook Handling: The Foundation

Your Stripe integration is only as reliable as your webhook handler. Here’s why: Stripe doesn’t guarantee that the redirect after Checkout will happen. The user might close their browser. Their network might drop. The only reliable signal that a payment succeeded is the webhook event that Stripe sends to your server.

The Basics

In SvelteKit, your webhook endpoint lives at something like src/routes/api/webhooks/stripe/+server.ts:

import { STRIPE_WEBHOOK_SECRET } from '$env/static/private';
import Stripe from 'stripe';

export async function POST({ request }) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature');

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed':
      // Provision access
      break;
    case 'customer.subscription.deleted':
      // Revoke access
      break;
  }

  return new Response('OK', { status: 200 });
}

What starters get wrong here:

  1. Not verifying signatures. Some starters skip constructEvent and just parse the JSON body. This means anyone can fake a webhook event and give themselves a subscription.

  2. Using request.json() instead of request.text(). The signature verification needs the raw body. If you parse it first, the signature check will always fail. This is the single most common bug in SvelteKit Stripe integrations.

  3. Not handling idempotency. Stripe may send the same event multiple times. Your handler needs to be idempotent — processing the same event twice shouldn’t create duplicate records or double-provision access.

  4. Returning errors on unknown event types. Your endpoint will receive events you don’t care about. Return 200 for all of them, or Stripe will keep retrying and eventually disable your endpoint.

Subscription Lifecycle: Beyond Checkout

A subscription has a lifecycle. Checkout is the birth. Here’s everything else:

Events You Must Handle

  • customer.subscription.created — Initial subscription creation
  • customer.subscription.updated — Plan changes, quantity changes, trial endings
  • invoice.payment_succeeded — Successful renewal
  • invoice.payment_failed — Failed renewal (card declined, expired, etc.)
  • customer.subscription.past_due — Grace period; user hasn’t paid but isn’t canceled yet
  • customer.subscription.deleted — Subscription is gone, revoke access

The Dunning Problem

When a card fails on renewal, Stripe enters a retry cycle (dunning). By default, it retries 3 times over ~3 weeks before canceling. During this period, you need to:

  1. Notify the user that their payment failed
  2. Show a banner prompting them to update their card
  3. Decide whether to maintain access during the retry period or restrict immediately

Most starters treat subscription status as binary: active or not. Reality is a spectrum: active, past_due, incomplete, trialing, canceled, unpaid. Your UI needs to handle all of these.

The Customer Portal

Stripe’s Customer Portal lets users manage their own subscriptions — update payment methods, change plans, cancel, view invoices. It’s free, hosted by Stripe, and takes about 10 minutes to configure.

And yet most starters build a custom billing page from scratch. Why? Because it looks more “professional”? The Customer Portal:

  • Handles PCI compliance for you
  • Supports 3D Secure / SCA automatically
  • Updates when Stripe adds new features
  • Is localized into 30+ languages

The correct integration is: link to the portal from your app’s billing page. Build custom UI only for things the portal doesn’t handle (usage-based displays, team management, etc.).

// src/routes/api/billing/portal/+server.ts
export async function POST({ locals }) {
  const session = await stripe.billingPortal.sessions.create({
    customer: locals.user.stripeCustomerId,
    return_url: `${BASE_URL}/settings/billing`,
  });

  return redirect(303, session.url);
}

How Our Starters Compare

PayKit

PayKit ($49) is intentionally minimal — Better Auth + Stripe Checkout. It handles the checkout flow correctly (signature verification, proper webhook endpoint) but doesn’t include subscription lifecycle management. It’s for one-time payments or simple subscriptions where you don’t need dunning logic.

Good for: selling a single product, lifetime deals, simple monthly access. Not enough for: multi-tier SaaS with plan switching, usage-based billing, team subscriptions.

OpenSaaS Svelte

OpenSaaS Svelte (free) includes webhook handling for the core subscription events and uses Better Auth for identity. It covers the checkout-to-active flow and basic cancellation. Being free and open-source, you can inspect exactly what it handles before committing.

It’s the best free option for getting subscription basics right, but you’ll still need to build dunning notifications and the past_due UI yourself.

SvelteSaaS

SvelteSaaS ($149) is the most complete Stripe integration in our directory. It handles plan switching (with proration), webhook verification, subscription status management across the full lifecycle, and integrates the Customer Portal. Lucia for auth, Drizzle for the database layer, Supabase for hosting.

If you’re building a real SaaS product and want to skip the 2-3 weeks of Stripe plumbing: this is where the $149 goes.

SvelteStack Pro

SvelteStack Pro ($199) includes Stripe among its kitchen-sink of integrations. The billing setup is solid but it’s part of a much larger package. If you need everything (auth, billing, admin, email, file uploads), it’s efficient. If you only need billing, you’re paying for a lot you won’t use.

Testing: The Part Everyone Skips

Stripe CLI for Local Development

stripe listen --forward-to localhost:5173/api/webhooks/stripe

This forwards webhook events to your local machine. The CLI prints a webhook signing secret for local development — use it as your STRIPE_WEBHOOK_SECRET in .env.

Test Clock

Stripe’s Test Clocks let you simulate the passage of time for subscriptions. You can advance a subscription through its entire lifecycle — trial, active, past_due, canceled — in minutes instead of waiting days.

If your starter doesn’t mention test clocks in its README: the developer probably tested the checkout flow and called it done.

Common Testing Gaps

  • 3D Secure flows. Cards in certain regions require additional authentication. Test with 4000002760003184 to trigger the challenge flow.
  • Declined renewals. Use 4000000000000341 — succeeds on first charge, fails on subsequent charges. Perfect for testing dunning.
  • Webhook replay. After a deployment, replay recent events from the Stripe dashboard to ensure nothing was missed during downtime.

Error States Your UI Needs

Here’s a checklist. If your billing page can’t handle these, you’re shipping bugs:

  • Card declined during initial checkout
  • Card declined on renewal (subscription goes past_due)
  • User’s card expires before next renewal
  • 3D Secure challenge fails or times out
  • User cancels mid-cycle (show remaining access period)
  • Plan upgrade with proration (show the adjusted amount)
  • Plan downgrade at period end (show when the change takes effect)
  • Webhook endpoint goes down (events queue and replay)

The Bottom Line

If you’re building something that charges money, Stripe integration isn’t a feature you bolt on — it’s foundational infrastructure. A starter that only handles the checkout redirect is saving you maybe an hour of work. The real time sink is everything that comes after.

For one-time payments or simple access control: PayKit is fine.

For a real SaaS: SvelteSaaS or OpenSaaS Svelte will save you weeks. The $149 for SvelteSaaS is a bargain compared to building (and debugging) subscription lifecycle handling from scratch.

And regardless of which starter you use: set up the Stripe CLI, test with failing cards, advance a test clock through a full billing cycle, and handle every status a subscription can be in. Your future self at 3am will thank you.

Get new starters in your inbox

Monthly picks, reviews, and the occasional deep-dive.

No spam. Unsubscribe anytime.

Need pre-built UI components for your SvelteKit project? Check out svelteblocks.com →