Add Stripe Payments to SvelteKit: Checkout + Webhooks Tutorial

15 May 2026 · Bank K.

Add Stripe Checkout to your SvelteKit app with form actions, server endpoints, and a working webhook handler. Code examples and the gotchas no one mentions.

You’ve built something in SvelteKit, and now you want to charge for it. Stripe is the obvious choice, but the official docs lean heavily on Next.js examples and Stripe’s framework-agnostic React snippets. SvelteKit handles requests differently — form actions, server endpoints, hooks, and +server.ts files all behave their own way. The Stripe code you copy from a Next.js tutorial won’t work as-is.

Here’s the working setup for adding Stripe Checkout to a SvelteKit app, including form actions, the webhook endpoint, and the parts that tend to break in production.

What You Need Before Writing Any Code

The setup before any code:

  • A SvelteKit 2.x project (works with both Svelte 4 and Svelte 5)
  • A Stripe account with test mode enabled
  • The Stripe Node SDK: npm install stripe
  • A deployment target that supports server-side rendering — Vercel, Netlify, Cloudflare, or your own Node server. Static-only builds will not work because you need server endpoints.
  • Stripe Product and Price objects created in the Stripe Dashboard (one-time or recurring, your choice)
  • Your publishable key, secret key, and webhook signing secret stored as env vars

Your .env should look like this:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
PUBLIC_BASE_URL=http://localhost:5173

The PUBLIC_ prefix matters — SvelteKit only exposes env vars to the client when they start with PUBLIC_. The secret key and webhook secret should never leak into client bundles.

The Architecture in One Diagram

User clicks "Subscribe"

SvelteKit form action (server-side)

Stripe.checkout.sessions.create()

Redirect user to checkout.stripe.com

User pays → Stripe redirects to /success

Stripe sends webhook → /api/webhooks/stripe

Your DB updates: user.plan = 'pro'

The two server-side touchpoints are the form action (to create the Checkout session) and the webhook handler (to confirm the payment actually happened). Everything else is either a redirect or a UI page.

Skip the webhook step and you have a security problem: users who never pay can still hit your success URL by typing it into the address bar. Always confirm payment server-side via the webhook.

Step 1: Create the Stripe Client

Make a single file that exports a configured Stripe instance. Reuse it everywhere instead of creating a new client per request.

// src/lib/server/stripe.ts
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY } from '$env/static/private';

export const stripe = new Stripe(STRIPE_SECRET_KEY, {
  apiVersion: '2025-02-24.acacia',
});

The src/lib/server/ directory is special in SvelteKit — anything in there can never be imported into client-side code, even by accident. This is a good place for anything that touches secret keys.

Step 2: The Form Action for Checkout

Form actions live in +page.server.ts alongside your +page.svelte. Here’s a pricing page that handles a “Subscribe” submission:

// src/routes/pricing/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { stripe } from '$lib/server/stripe';
import { PUBLIC_BASE_URL } from '$env/static/public';
import type { Actions } from './$types';

export const actions: Actions = {
  subscribe: async ({ request, locals }) => {
    const formData = await request.formData();
    const priceId = formData.get('priceId') as string;

    const user = locals.user; // however you load your user
    if (!user) {
      throw redirect(303, '/login?redirect=/pricing');
    }

    const session = await stripe.checkout.sessions.create({
      mode: 'subscription',
      payment_method_types: ['card'],
      customer_email: user.email,
      client_reference_id: user.id,
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${PUBLIC_BASE_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${PUBLIC_BASE_URL}/pricing`,
      allow_promotion_codes: true,
    });

    if (!session.url) {
      throw new Error('Stripe did not return a checkout URL');
    }

    throw redirect(303, session.url);
  },
};

A few things worth flagging:

  • client_reference_id is how you tie the Checkout session back to your own user. It shows up in the webhook payload — you’ll use it to find the right user record when the payment completes.
  • customer_email pre-fills the email field on the Stripe page. If you store a Stripe customer ID for the user already, use customer: user.stripeCustomerId instead so the user’s payment methods are remembered.
  • The throw redirect() pattern is SvelteKit-specific. It cancels the form action and sends a 303 to the browser. Returning the URL and redirecting client-side also works, but the server redirect is cleaner.

Step 3: The Form on the Pricing Page

The matching +page.svelte is minimal:

<!-- src/routes/pricing/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
</script>

<h1>Pricing</h1>

<form method="POST" action="?/subscribe" use:enhance>
  <input type="hidden" name="priceId" value="price_1ProMonthly..." />
  <button type="submit">Subscribe — $19/mo</button>
</form>

<form method="POST" action="?/subscribe" use:enhance>
  <input type="hidden" name="priceId" value="price_1ProAnnual..." />
  <button type="submit">Subscribe — $190/yr</button>
</form>

use:enhance is SvelteKit’s progressive enhancement helper. With it, the form posts via fetch instead of a full page reload. Without it, the form still works — it just refreshes. Either way is fine.

The price IDs go in the hidden inputs. In production, store them as env vars or in a database table instead of hardcoding.

Step 4: The Webhook Endpoint

Webhooks are how Stripe tells your server “yes, the payment actually went through.” They live in src/routes/api/webhooks/stripe/+server.ts:

// src/routes/api/webhooks/stripe/+server.ts
import { stripe } from '$lib/server/stripe';
import { STRIPE_WEBHOOK_SECRET } from '$env/static/private';
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';

export const POST: RequestHandler = async ({ request }) => {
  const signature = request.headers.get('stripe-signature');
  if (!signature) throw error(400, 'Missing stripe-signature header');

  const body = await request.text();

  let event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.error('Webhook signature verification failed', err);
    throw error(400, 'Invalid signature');
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object;
      const userId = session.client_reference_id;
      const customerId = session.customer as string;
      const subscriptionId = session.subscription as string;

      // Update your DB
      await markUserAsSubscribed(userId, customerId, subscriptionId);
      break;
    }
    case 'customer.subscription.updated': {
      const sub = event.data.object;
      await syncSubscriptionStatus(sub.customer as string, sub.status);
      break;
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object;
      await markUserAsCancelled(sub.customer as string);
      break;
    }
  }

  return json({ received: true });
};

The critical detail: read the raw request body as text, not as JSON. Stripe verifies the signature against the exact bytes of the request body. If you parse it as JSON first, the body gets re-serialized and the signature check fails.

SvelteKit doesn’t auto-parse JSON for endpoints, so request.text() gives you the raw string. This is one place where SvelteKit is actually easier than Next.js — no need to disable body parsing.

Step 5: The Success Page

After payment, Stripe redirects to your success URL with a session_id query parameter. Use it to display confirmation and double-check the payment:

// src/routes/billing/success/+page.server.ts
import { stripe } from '$lib/server/stripe';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ url }) => {
  const sessionId = url.searchParams.get('session_id');
  if (!sessionId) throw error(400, 'Missing session_id');

  const session = await stripe.checkout.sessions.retrieve(sessionId);

  if (session.payment_status !== 'paid') {
    throw error(400, 'Payment not completed');
  }

  return {
    customerEmail: session.customer_details?.email,
    amountTotal: session.amount_total,
  };
};

Don’t rely on the success page to grant access — the webhook does that. The success page is just user-facing confirmation. If the user closes the tab before the webhook fires, the webhook still works. If the success URL is wrong, the user still gets access. Always keep the source of truth on the server side.

Local Webhook Testing

You can’t receive webhooks on localhost without a tunnel. The Stripe CLI handles this:

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

The CLI prints a webhook signing secret starting with whsec_. Paste that into your .env as STRIPE_WEBHOOK_SECRET. Now every test payment triggers a real webhook to your dev server.

You can also trigger specific events without making a payment:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated

Useful for testing edge cases without running through the full Checkout flow each time.

Common Gotchas in Production

A few things that catch people on first deploy:

  1. The webhook secret differs between local and production. Each Stripe webhook endpoint in the Dashboard has its own signing secret. Don’t reuse the CLI’s secret in production — go to Dashboard → Developers → Webhooks → Add Endpoint, register your production URL, and copy the new secret.

  2. PUBLIC_BASE_URL needs to match the deployed domain. If you hardcode http://localhost:5173, the success redirect breaks in production. Set it as an environment variable per environment.

  3. Webhooks must respond within 30 seconds. If you do heavy work in the handler (like sending welcome emails, calling external APIs, generating PDFs), queue it instead of running it inline. Return 200 OK fast, then process async.

  4. Adapter matters for Cloudflare. Cloudflare Workers can’t use Node’s crypto module directly. The Stripe SDK has a Cloudflare-compatible path — use stripe.webhooks.constructEventAsync() instead of constructEvent() on Workers.

  5. Idempotency for retries. Stripe retries failed webhooks. If your handler isn’t idempotent (e.g., creating a duplicate user record), you’ll have problems. Store the event ID and check before processing.

Faster Path: Skip the Boilerplate

What’s shown above is the working pattern, but it’s still 200+ lines of code, plus auth, plus the user database, plus customer portal handling, plus subscription state management. Most of this is the same for every SaaS app.

Beag.io ships auth and Stripe payments for SvelteKit (and Astro, Next.js, Remix) as a drop-in solution — login, signup, social auth, Stripe Checkout, customer portal, subscription state all included. If you’re building an MVP and want to spend time on the actual product instead of the payment plumbing, that’s the trade-off. See the Stripe payments for React guide and Stripe subscriptions for Astro tutorial for the same pattern in other frameworks.

FAQ

Can I use SvelteKit form actions with Stripe Checkout?

Yes. Form actions are the cleanest way to handle the “create checkout session” step. The action runs on the server, creates the Stripe session, and uses throw redirect() to send the user to Stripe’s hosted checkout. The form can be progressively enhanced with use:enhance but works without JavaScript.

Do I need a database for SvelteKit + Stripe?

Yes, in any real app. You need to store the user record, their Stripe customer ID, and their subscription status. Without a database, you can’t tell which users are paying customers when they come back. Use whatever you prefer — Postgres, SQLite via Turso, Supabase, or Convex.

Why does the webhook signature verification fail?

The most common cause is reading the request body as JSON before passing it to constructEvent. Stripe verifies the signature against the exact raw bytes — re-serializing destroys the signature. Use request.text() and pass the string directly. The second most common cause is a stale or wrong STRIPE_WEBHOOK_SECRET env var.

Should I use Stripe Checkout or the Payment Element?

Stripe Checkout (the hosted page) is the fastest to integrate and stays compliant as Stripe updates payment methods. The Payment Element gives you a Stripe-styled form embedded in your own page, with more design control. For most SaaS MVPs, hosted Checkout is the right call. Move to Payment Element when you need to match your brand more tightly or handle one-page upgrade flows.

How do I handle subscription cancellations in SvelteKit?

Use the Stripe Customer Portal. Create a portal session on your server (similar to the Checkout session pattern), redirect the user to the portal URL, and let Stripe handle the UI for managing payment methods, downloading invoices, and cancelling. When the user cancels, Stripe sends a customer.subscription.updated or customer.subscription.deleted webhook — handle that in your webhook endpoint to update your database.

Is SvelteKit + Stripe production-ready?

Yes. The pattern above is what’s running in production for many SvelteKit SaaS apps. The only edge cases are deploying to Cloudflare Workers (use constructEventAsync) and very high-volume webhook traffic (queue the heavy work). For the typical indie hacker or small SaaS, this setup handles thousands of customers without modification.

Wrap-Up

The full SvelteKit + Stripe setup is: one server-side Stripe client, one form action to create a Checkout session, one webhook endpoint to confirm payment, and one success page. That’s the working pattern.

If you want to skip the boilerplate and go straight to charging customers, Beag.io adds auth and Stripe to your app in 5 minutes. Otherwise, copy the code above, set your env vars, and you’re done.

About the Author
Bank K.

Bank K.

Serial entrepreneur & Co-founder of Beag.io

Founder of Beag.io. Indie hacker building tools to help developers ship faster.

Ready to Make Money From Your SaaS?

Turn your SaaS into cash with Beag.io. Get started now!

Start 7-day free trial →