Integrating Stripe with SvelteKit: Dynamic pricing and Payment Security
Introduction
Integrating
Stripe
into a SvelteKit application, especially when dealing with dynamic product pricing and robust webhook validation, can be challenging. While building a client's business application using SvelteKit and PostgreSQL (with Drizzle ORM), I encountered the need for a streamlined integration with Stripe. The goal was to use minimal dependencies while supporting features such as dynamic pricing, product images, and secure payment verification via webhooks, all without requiring database updates. This article provides a comprehensive guide to this entire process, addressing a gap in existing resources. It should be noted that we cover the concepts here, and as a result, it's framework agnostic. You can use NextJS
or NuxtJS
in place of SvelteKit
since they support server endpoints, form actions, etc.
Live version
Source code
System architecture and requirements
To demonstrate this integration, we will build a minimalist digital book application. The application will allow users to browse books, add them to a cart, and purchase them using Stripe. Access to purchased content will be granted based on the email provided during checkout.
Prerequisite
You need to create a new SvelteKit application. If you do not have one yet, just follow these commands:
npx sv create digibooks
When prompted during npx sv create digibooks
:
- Choose
SvelteKit minimal (barebones scaffolding for your new app)
when askedWhich Svelte app template?
. - Select your preferred options for other features. For this project, I opted for TypeScript syntax, Prettier, ESLint, Tailwind CSS, and Drizzle ORM (with
libSQL
).
Also, we will be using the `stripe` library, which should be installed:
npm install stripe
Implementation
Step 1: Create the database schema
When you opt for drizzle ORM while creating the project with npx sv create
, src/lib/server/db/index.ts
(with a .env
entry for DATABASE_URL
), src/lib/server/db/schema.ts
and drizzle.config.ts
alongside db:push
, db:migrate
and db:studio
entries in package.json
will be made available. Let's open src/lib/server/db/schema.ts
and populate it with:
import { sql } from "drizzle-orm";
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";
export const books = sqliteTable("books", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
author: text("author").notNull(),
description: text("description"),
coverImageUrl: text("cover_image_url"),
// Store price in cents to avoid floating point issues
priceInCents: integer("price_in_cents").notNull(),
});
export const purchases = sqliteTable("purchases", {
id: integer("id").primaryKey({ autoIncrement: true }),
quantity: integer("quantity").notNull().default(1),
customerEmail: text("customer_email").notNull(),
bookId: integer("book_id")
.notNull()
.references(() => books.id, { onDelete: "cascade" }), // Optional: onDelete behavior
purchasedAt: integer("purchased_at", { mode: "timestamp" })
.notNull()
.default(sql`(strftime('%s', 'now'))`),
stripeCheckoutSessionId: text("stripe_checkout_session_id").notNull(),
isCompleted: integer("is_completed", { mode: "boolean" })
.notNull()
.default(false), // Default to false, indicating the purchase is not completed
});
Just some very basic SQL tables to list books and record purchases linked to customer emails, which can then be used to grant access to the digital content.
To effect this change, run:
npm run db:push
and choose the Yes
when prompted. Now, our database tables have been created. We will also add a simple "money" formatter that takes into consideration the cents
-based pricing in src/lib/utils/helpers.ts
:
/**
* Format a number as currency
*
* @param {number | string | null | undefined} amount - Amount to format in cents
* @returns {string} - Formatted currency string
*/
export const formatMoney = (
amount: number | string | null | undefined
): string => {
if (amount === null || amount === undefined || isNaN(Number(amount))) {
return "$0.00"; // Default to $0.00 if input is invalid
}
const validAmount = (Number(amount) || 0) / 100; // Convert cents to dollars
return validAmount.toLocaleString("en-US", {
style: "currency",
currency: "USD",
currencyDisplay: "narrowSymbol",
});
};
To support a Turso-hosted SQLite instance, we need to modify src/lib/server/db/index.ts
and drizzle.config.ts
to avoid this error:
LibsqlError: SERVER_ERROR: Server returned HTTP status 401
at mapHranaError (~/digibooks/node_modules/@libsql/client/lib-esm/hrana.js:279:16)
at file:///Users/johnidogun/Documents/projects/digibooks/node_modules/@libsql/client/lib-esm/http.js:76:23
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
at async Object.query (~/digibooks/node_modules/drizzle-kit/bin.cjs:79275:25)
at async fromDatabase3 (~/digibooks/node_modules/drizzle-kit/bin.cjs:20507:23) {
code: 'SERVER_ERROR',
rawCode: undefined,
[cause]: HttpServerError: Server returned HTTP status 401
at errorFromResponse (~/digibooks/node_modules/@libsql/hrana-client/lib-esm/http/stream.js:352:16)
at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {
status: 401
}
}
Open up drizzle.config.ts
and make it look like this:
import { defineConfig } from "drizzle-kit";
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
if (!process.env.DATABASE_AUTH_TOKEN)
throw new Error("DATABASE_AUTH_TOKEN is not set");
export default defineConfig({
schema: "./src/lib/server/db/schema.ts",
dialect: process.env.DATABASE_URL?.startsWith("file:") ? "sqlite" : "turso",
dbCredentials: {
url: process.env.DATABASE_URL,
authToken: process.env.DATABASE_AUTH_TOKEN,
},
verbose: true,
strict: true,
});
and src/lib/server/db/index.ts
:
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
import { env } from "$env/dynamic/private";
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
if (!env.DATABASE_AUTH_TOKEN) throw new Error("DATABASE_AUTH_TOKEN is not set");
const client = createClient({
url: env.DATABASE_URL,
authToken: env.DATABASE_URL.startsWith("file:")
? undefined
: env.DATABASE_AUTH_TOKEN,
});
export const db = drizzle(client, { schema });
We are conditionally (based on the DATABASE_URL
) changing the database dialect to either sqlite
or turso
. If turso
and authToken
are required to successfully connect to the instance. The dialect
in drizzle.config.ts
is conditionally set to turso
when a non-file DATABASE_URL
is used, which allows dbCredentials
to correctly utilize the authToken
. If you are not using a Turso SQLite instance, you may not need to worry about this.
Seeding the database
So that we can have enough data to work with, this article's GitHub repository contains two files:
`books.json`
and
`seed.ts`
. The former contains some dummy data in JSON, while the latter has the code to load (not optimized) this data into the DB. I also added an entry in the script
section of
`package.json`
where we utilize the experimental node's --experimental-transform-types
flag to run the TypeScript file. You can run these commands in succession (locally) after deploying or at the start of development of your app:
npm run db:push # handles everything database migration
npm run db:seed # runs the seeding logic
Step 2: Cart and carting store
This app will not deal with the intricacies of authentication, so there will not be "users". However, we still need a way to keep track of what the current user wants to buy. As a result, we need a carting system that won't need to store its data in the database. We will combine the browser's localStorage
and Svelte 5 runes to achieve a reliable reactivity. Create a src/lib/states/carts.svelte.ts
and populate it with:
import { browser } from "$app/environment";
import type { Book } from "$lib/server/db/schema";
import type { CartItem } from "$lib/types/cart";
import { formatMoney } from "$lib/utils/helpers";
// Load cart from localStorage if available
function loadCartFromStorage(): CartItem[] {
if (!browser) return [];
try {
const saved = localStorage.getItem("digibooks-cart");
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
// Save cart to localStorage
function saveCartToStorage(cart: CartItem[]): void {
if (!browser) return;
try {
localStorage.setItem("digibooks-cart", JSON.stringify(cart));
} catch (error) {
console.error("Failed to save cart to localStorage:", error);
}
}
// Cart state management using Svelte 5 runes
function createCartState() {
let items = $state<CartItem[]>(loadCartFromStorage());
function addItem(book: Book) {
const existingItem = items.find((item) => item.book.id === book.id);
if (existingItem) {
existingItem.quantity += 1;
} else {
items.push({ book, quantity: 1 });
}
saveCartToStorage(items);
}
function removeItem(bookId: number) {
items = items.filter((item) => item.book.id !== bookId);
saveCartToStorage(items);
}
function updateQuantity(bookId: number, quantity: number) {
if (quantity <= 0) {
removeItem(bookId);
return;
}
const item = items.find((item) => item.book.id === bookId);
if (item) {
item.quantity = quantity;
saveCartToStorage(items);
}
}
function clear() {
items = [];
saveCartToStorage(items);
}
function getTotalItems(): number {
return items.reduce((total, item) => total + item.quantity, 0);
}
function getTotalPrice(): number {
return items.reduce(
(total, item) => total + item.book.priceInCents * item.quantity,
0
);
}
function getFormattedTotal(): string {
return formatMoney(getTotalPrice());
}
return {
get items() {
return items;
},
addItem,
removeItem,
updateQuantity,
clear,
getTotalItems,
getTotalPrice,
getFormattedTotal,
};
}
// Create a singleton instance
export const cartState = createCartState();
This is a basic CRUD
carting process while making items
reactive using the power of Svelte 5 rune. We referenced a type here, and this is the definition:
import type { Book } from '$lib/server/db/schema';
// Cart item interface
export interface CartItem {
book: Book;
quantity: number;
}
export interface SessionMetadata {
customerEmail?: string;
userId?: string; // Optional user ID for authenticated users
timestamp: string; // ISO string for when the session was created
books: string; // Array of book IDs and their quantities
itemCount?: string; // Number of items in the cart
totalAmount?: string; // Total amount in cents as a string
}
export interface PaginationMetadata {
page: number;
limit: number;
total: number;
totalPages: number;
}
=
Just some additional types that will be used later. Now, we can add a simple utility function (not extremely necessary) for the addToCart functionality:
...
/**
* Add a book to the cart
*
* @param {Book} book - The book to add to the cart
*/
export const addToCart = (book: Book) => {
cartState.addItem(book);
};
Step 3: Data loading and frontend
Though we will not delve into the intricacies of styles and tailwindcss stuff as those are not the focus of this article, beware that I made custom changes to the app's
`src/app.css`
,
`src/routes/+layout.svelte`
and of course, each of the pages: src/routes/+page.svelte
, src/routes/[id]/+page.svelte
,src/routes/cart/+page.svelte
, and src/routes/purchases/+page.svelte
with some glasmorphism effects.
Let's see what src/routes/+page.server.ts
looks like:
import { db } from "$lib/server/db";
import { books } from "$lib/server/db/schema";
import { or, type SQL, like, asc, desc, count } from "drizzle-orm";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ url }) => {
const searchQuery = url.searchParams.get("search");
const sortBy = url.searchParams.get("sort") || "featured";
const page = parseInt(url.searchParams.get("page") || "1");
const limit = 8;
const offset = (page - 1) * limit;
const whereClause: SQL[] = [];
// Apply search filter
if (searchQuery) {
whereClause.push(
like(books.title, `%${searchQuery}%`),
like(books.author, `%${searchQuery}%`)
);
}
const orderByClause: SQL[] = [];
// Apply sorting
switch (sortBy) {
case "price-low":
orderByClause.push(asc(books.priceInCents));
break;
case "price-high":
orderByClause.push(desc(books.priceInCents));
break;
case "title":
orderByClause.push(asc(books.title));
break;
case "author":
orderByClause.push(asc(books.author));
break;
default: // featured
orderByClause.push(asc(books.id));
break;
}
// Execute both queries in parallel
const [countResult, allBooks] = await Promise.all([
// Count query
db
.select({ count: count() })
.from(books)
.where(whereClause.length > 0 ? or(...whereClause) : undefined)
.then((result) => result[0]),
// Books query with pagination
db
.select()
.from(books)
.where(whereClause.length > 0 ? or(...whereClause) : undefined)
.orderBy(...orderByClause)
.limit(limit)
.offset(offset),
]);
return {
books: allBooks || [],
pagination: {
total: countResult.count,
page,
limit,
totalPages: Math.ceil(countResult.count / limit),
},
searchQuery,
sortBy,
};
};
$types
:
SvelteKit automatically generates TypeScript definitions for your routes, including the data shapes for load
functions and page props. When you see import type { PageServerLoad } from "./$types";
or use PageData
(often imported from ./$types
as well, or inferred), these types are derived from your load
function's return signature and any route parameters. This provides excellent type safety between your server-side data loading and your client-side Svelte components. If you modify the data structure returned by a load
function, SvelteKit's type generation will reflect these changes, helping you catch errors at build time.
It simply fetches data from the DB with search, filtering, and pagination support. To prevent request waterfalls, we use Promise.all
to run the counting and data retrieval queries in parallel. This can improve performance. In src/routes/+page.svelte
, we simply get this data via the data
props and render them, excluding other features:
<script lang="ts">
import BookCard from '$lib/components/BookCard.svelte';
import Pagination from '$lib/components/utils/Pagination.svelte';
import { addToCart } from '$lib/utils/helpers';
...
let { data }: { data: PageData } = $props();
...
</script>
<svelte:head>
<title>Books - DigiBooks</title>
</svelte:head>
<div class="space-y-8">
<!-- Clean Header -->
<div class="text-center">
<h1 class="text-content mb-3 text-4xl font-bold">Digital Books</h1>
<p class="text-content-muted">Discover your next great read</p>
</div>
...
<!-- Books Grid/List -->
{#if data.books.length > 0}
<div
class="grid gap-6 transition-all duration-300"
class:grid-cols-1={viewMode === 'list'}
class:sm:grid-cols-2={viewMode === 'grid'}
class:lg:grid-cols-3={viewMode === 'grid'}
class:xl:grid-cols-4={viewMode === 'grid'}
>
{#each data.books as book, index (book.id)}
<div in:fly={{ y: 20, duration: 300, delay: index * 30 }}>
<BookCard {book} {viewMode} addToCart={() => addToCart(book)} />
</div>
{/each}
</div>
...
<!-- Pagination -->
{#if data.pagination.totalPages > 1}
<Pagination metadata={data.pagination} />
{/if}
</div>
Refer to the repository for the full code and the BookCard
and Pagination
components. Same goes with src/routes/[id]/+page.svelte
where its load function is quite basic:
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { db } from "$lib/server/db";
import { books } from "$lib/server/db/schema";
import { eq } from "drizzle-orm";
export const load: PageServerLoad = async ({ params }) => {
const book = await db
.select()
.from(books)
.where(eq(books.id, Number(params.id)))
.get();
if (!book) {
error(404, "Book not found");
}
return {
book,
};
};
Then comes the /purchases
route, which provides an interface for users to see the products they purchased. It requires that users input their email address. This is the page's +page.server.ts
:
import { db } from "$lib/server/db";
import { purchases, books } from "$lib/server/db/schema";
import { eq, and, desc } from "drizzle-orm";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ url }) => {
const email = url.searchParams.get("email");
if (!email) {
return {
purchases: [],
email: null,
};
}
try {
// Fetch purchases with book details for the given email
const userPurchases = await db
.select({
id: purchases.id,
quantity: purchases.quantity,
customerEmail: purchases.customerEmail,
purchasedAt: purchases.purchasedAt,
stripeCheckoutSessionId: purchases.stripeCheckoutSessionId,
isCompleted: purchases.isCompleted,
book: {
id: books.id,
title: books.title,
author: books.author,
description: books.description,
coverImageUrl: books.coverImageUrl,
priceInCents: books.priceInCents,
},
})
.from(purchases)
.innerJoin(books, eq(purchases.bookId, books.id))
.where(
and(eq(purchases.customerEmail, email), eq(purchases.isCompleted, true))
)
.orderBy(desc(purchases.purchasedAt));
return {
purchases: userPurchases,
email,
};
} catch (error) {
console.error("Failed to fetch purchases:", error);
return {
purchases: [],
email,
error: "Failed to load purchases",
};
}
};
To reduce trips to the Database, we joined the purchases
and books
. Its frontend simply displays this data.
In the next subsection, we will finally integrate Stripe!
Step 4: Payment integration
It's now time to integrate Stripe with our app so that users can securely pay for their books of choice. There are multiple ways to integrate Stripe or accept payments with Stripe:
- Prebuilt Checkout page: Stripe hosts the payment page. You redirect users to Stripe, and Stripe redirects them back to your site after payment. This is the quickest way to get started and ensures PCI compliance is handled by Stripe.
- Payment Element: Embeddable UI components that allow you to design a custom payment form on your site while still leveraging Stripe's infrastructure for processing and PCI compliance. Offers more customization than the prebuilt Checkout page.
- Custom payment flow: Build your entire payment UI from scratch and use Stripe APIs (like Stripe.js on the frontend and the Stripe SDK on the backend) to process payments. This offers maximum control over the user experience but also requires more effort to implement and manage PCI compliance.
In this article, we'll go with the first option. However, there is a plan to extend to the other two in future articles (as well as integrate other payment processors such as Squareup
and Braintree
).
To start, create a payments.ts
file in src/lib/server/payments.ts
(you can use server
in NextJS):
import { STRIPE_SECRET_KEY } from "$env/static/private";
import type { CartItem, SessionMetadata } from "$lib/types/cart";
import Stripe from "stripe";
export const stripe = new Stripe(STRIPE_SECRET_KEY);
export async function createCheckoutSession({
items,
successUrl,
cancelUrl,
customerEmail,
metadata,
}: {
items: CartItem[];
successUrl: string;
cancelUrl: string;
customerEmail?: string;
metadata?: SessionMetadata;
}) {
try {
// Convert items to Stripe line items
const lineItems = items.map((item) => ({
price_data: {
currency: "usd",
product_data: {
name: item.book.title,
description: item.book.description || undefined,
images: item.book.coverImageUrl
? [item.book.coverImageUrl]
: undefined,
metadata: {
bookId: item.book.id,
},
},
unit_amount: item.book.priceInCents,
},
quantity: item.quantity,
}));
const sessionParams: Stripe.Checkout.SessionCreateParams = {
line_items: lineItems,
mode: "payment",
success_url: successUrl,
cancel_url: cancelUrl,
billing_address_collection: "required",
payment_intent_data: {
metadata: {
...metadata,
source: "digibooks",
},
},
metadata: {
...metadata,
},
};
// Add customer email if provided
if (customerEmail) {
sessionParams.customer_email = customerEmail;
}
const session = await stripe.checkout.sessions.create(sessionParams);
return {
success: true,
sessionId: session.id,
url: session.url,
};
} catch (error) {
console.error("Stripe checkout session creation failed:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to create checkout session",
};
}
}
To instantiate a Stripe instance, a STRIPE_SECRET_KEY
is required — gotten from your Stripe developer dashboard. In this function, we only touched on some of the data accepted by a
Stripe checkout session
. You can provide much more data depending on your needs. In the code, we supplied the products (books) being paid for alongside their images and descriptions with the unit amount (in cents, stripe takes cents for USD and EUR. It takes whatever is the of your supported currency. This is to avoid floating point issues). The mode is a one-time payment
. You could use subscription
for recurring payments or setup
to charge customers later. We also provided the routes for successful and unsuccessful processing. We even made collecting customers' billing addresses mandatory. After a payment has been confirmed (via a webhook, to be implemented later), we need some data about the books and other things the user bought so we can fullfil them (here create purchases
entry(ies)), that's a potential use case of Stripe.Checkout.SessionCreateParams.metadata
. It only takes primitives (string
, number
, null
) as objects' values, though that's why the books
property in SessionMetadata
is a string (JSON string of an array of bookId
and quantity
). This is just the tip of the iceberg. You can do much more. After supplying these data, Stripe internally sends requests to its API(s), and a checkout session identification is returned alongside other important details such as the section URL (where you should redirect users to for them to make payment). Here is a truncated example response:
{
"id": "cs_test_a11YYufWQzNY63zpQ6QSNRQhkUpVph4WRmzW0zWJO2znZKdVujZ0N0S22u",
"object": "checkout.session",
...
"amount_subtotal": 2198,
"amount_total": 2198,
"automatic_tax": {
"enabled": false,
"liability": null,
"status": null
},
"billing_address_collection": null,
"cancel_url": null,
...
"created": 1679600215,
"currency": "usd",
...
"customer_email": null,
...
...
"metadata": {},
"mode": "payment",
"payment_intent": null,
"payment_link": null,
...
"success_url": "https://example.com/success",
"total_details": {
"amount_discount": 0,
"amount_shipping": 0,
"amount_tax": 0
},
"url": "https://checkout.stripe.com/c/pay/cs_test_a11YYufWQzNY63zpQ6QSNRQhkUpVph4WRmzW0zWJO2znZKdVujZ0N0S22u#fidkdWxOYHwnPyd1blpxYHZxWjA0SDdPUW5JbmFMck1wMmx9N2BLZjFEfGRUNWhqTmJ%2FM2F8bUA2SDRySkFdUV81T1BSV0YxcWJcTUJcYW5rSzN3dzBLPUE0TzRKTTxzNFBjPWZEX1NKSkxpNTVjRjN8VHE0YicpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl"
}
With that, we can now implement the form action that uses this function:
import { createCheckoutSession } from "$lib/server/payments";
import type { CartItem } from "$lib/types/cart";
import { fail, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
export const actions: Actions = {
checkout: async ({ request, url }) => {
const formData = await request.formData();
const cartItems = JSON.parse(
formData.get("cartItems") as string
) as CartItem[];
const customerEmail = formData.get("email") as string;
// Validate cart items
if (!cartItems || !Array.isArray(cartItems) || cartItems.length === 0) {
return fail(400, {
error: "Cart is empty",
});
}
const result = await createCheckoutSession({
items: cartItems,
customerEmail: customerEmail || undefined,
metadata: {
userId: "guest", // Replace with actual user ID if authenticated
timestamp: new Date().toISOString(),
books: JSON.stringify(
cartItems.map((item) => ({
bookId: item.book.id.toString(),
quantity: item.quantity,
}))
),
itemCount: cartItems
.reduce((total, item) => total + item.quantity, 0)
.toString(),
totalAmount: cartItems
.reduce(
(total, item) => total + item.book.priceInCents * item.quantity,
0
)
.toString(),
},
successUrl: `${url.origin}/cart?checkout=success`,
cancelUrl: `${url.origin}/cart?checkout=cancel`,
});
if (!result.success) {
return fail(500, {
error: result.error || "Failed to create checkout session",
});
}
// Redirect to Stripe checkout
redirect(303, result.url!);
},
};
We simply retrieve the needed data from the form, slightly process it, and send it to Stripe.
Wrapping a redirect
in try...catch
in SvelteKit form action almost always returns the `catch` block (with errors). You should handle errors using a different approach.
Now, let's see the cart page (only shows the form and URL handling part):
<script lang="ts">
import { formatMoney } from '$lib/utils/helpers';
...
let isLoading = $state(false),
email = $state(''),
redirectCountdown = $state(0),
isRedirecting = $state(false);
// Check for checkout status in URL params
const checkoutStatus = $derived(page.url.searchParams.get('checkout'));
function updateQuantity(bookId: number, quantity: number) {
cartState.updateQuantity(bookId, quantity);
}
function removeItem(bookId: number) {
cartState.removeItem(bookId);
}
function clearCart() {
cartState.clear();
}
// Handle checkout success/cancel
onMount(() => {
if (checkoutStatus === 'success') {
// Clear cart on successful checkout
cartState.clear();
...
}
});
</script>
<svelte:head>
<title>Cart - DigiBooks</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<!-- Header -->
<div class="mb-8">
<h1 class="mb-2 text-3xl font-bold text-gray-900">Shopping Cart</h1>
<p class="text-gray-600">
{cartState.items.length === 0
? 'Your cart is empty'
: `${cartState.getTotalItems()} ${cartState.getTotalItems() === 1 ? 'item' : 'items'}`}
</p>
</div>
<!-- Checkout Status Messages -->
{#if checkoutStatus === 'success'}
<div class="mb-6 rounded-lg border border-green-200 bg-green-50 p-6" in:fade>
<div class="flex items-start">
{@render check({ class: 'mr-3 mt-0.5 h-6 w-6 text-green-400', 'aria-hidden': 'true' })}
<div class="flex-1">
<h3 class="mb-2 font-medium text-green-800">Payment Successful!</h3>
<p class="mb-3 text-green-700">
Thank you for your purchase. Your books have been added to your library.
</p>
{#if isRedirecting}
<div class="flex items-center gap-2 text-sm text-green-600">
{@render loader({ class: 'h-4 w-4 animate-spin' })}
<span>Redirecting to your purchases in {redirectCountdown} seconds...</span>
</div>
<button
onclick={() => goto('/purchases')}
class="mt-3 text-sm font-medium text-green-600 underline hover:text-green-500"
>
Go to purchases now
</button>
{/if}
</div>
</div>
</div>
{:else if checkoutStatus === 'cancel'}
<div class="mb-6 rounded-lg border border-yellow-200 bg-yellow-50 p-4" in:fade>
<div class="flex items-center">
{@render alert({ class: 'mr-2 h-5 w-5 text-yellow-400', 'aria-hidden': 'true' })}
<p class="font-medium text-yellow-800">
Checkout was cancelled. Your items are still in your cart.
</p>
</div>
</div>
{/if}
<!-- Form Error -->
{#if page.form?.error}
...
{/if}
{#if cartState.items.length > 0}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-12">
<!-- Cart Items -->
<div class="lg:col-span-8">
<div class="overflow-hidden rounded-2xl border border-gray-200 bg-white">
<!-- Items Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900">Items</h2>
<button onclick={clearCart} class="text-sm font-medium text-red-600 hover:text-red-700">
Clear all
</button>
</div>
<!-- Items List -->
<div class="divide-y divide-gray-200">
{#each cartState.items as item, index (item.book.id)}
...
{/each}
</div>
</div>
</div>
<!-- Order Summary -->
<div class="lg:col-span-4">
<div class="sticky top-8 rounded-2xl border border-gray-200 bg-white p-6">
<h2 class="mb-6 text-lg font-semibold text-gray-900">Order Summary</h2>
<!-- Summary Details -->
<div class="mb-6 space-y-4">
<div class="flex justify-between text-gray-600">
<span>Subtotal ({cartState.getTotalItems()} items)</span>
<span>{formatMoney(cartState.getTotalPrice())}</span>
</div>
<div class="flex justify-between text-gray-600">
<span>Shipping</span>
<span class="font-medium text-green-600">Free</span>
</div>
<div class="flex justify-between text-gray-600">
<span>Tax</span>
<span>$0.00</span>
</div>
<hr class="border-gray-200" />
<div class="flex justify-between text-lg font-bold text-gray-900">
<span>Total</span>
<span class="text-blue-600">{cartState.getFormattedTotal()}</span>
</div>
</div>
<!-- Checkout Form -->
<form
method="POST"
action="?/checkout"
use:enhance={() => {
isLoading = true;
return async ({ update, result }) => {
await update();
isLoading = false;
if (result.type === 'redirect') {
window.location.href = result.location;
}
};
}}
class="space-y-4"
>
<!-- Hidden cart items -->
<input type="hidden" name="cartItems" value={JSON.stringify(cartState.items)} />
<!-- Email Input -->
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-700">
Email Address
</label>
<input
type="email"
id="email"
name="email"
bind:value={email}
required
disabled={isLoading}
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-gray-50 disabled:opacity-50"
placeholder="your@email.com"
/>
</div>
<!-- Action Buttons -->
<div class="space-y-3">
<button
type="submit"
disabled={isLoading || cartState.items.length === 0 || !email.trim()}
class="w-full rounded-xl bg-blue-600 px-4 py-3 font-medium text-white transition-all duration-200 hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:bg-gray-400"
>
{#if isLoading}
<div class="flex items-center justify-center gap-2">
{@render loader({ class: 'h-5 w-5 animate-spin' })}
<span>Processing...</span>
</div>
{:else}
Proceed to Checkout
{/if}
</button>
<a
href="/"
class="block w-full rounded-xl bg-gray-100 px-4 py-3 text-center font-medium text-gray-800 transition-colors hover:bg-gray-200"
>
Continue Shopping
</a>
</div>
</form>
<!-- Security Badge -->
<div class="mt-6 flex items-center justify-center gap-2 text-sm text-gray-500">
{@render shield({ class: 'h-5 w-5 text-blue-600' })}
<span>Secure checkout guaranteed</span>
</div>
</div>
</div>
</div>
{:else}
...
{/if}
</div>
Just some styles and progressive form enhancements.
Step 5: Confirming purchases with webhook
Currently, even after a successful payment, users cannot access what they paid for. This is unfair. While we strive to fulfill every payment made, we need to be careful, though, as people can game the payment flow, providing a fraudulent payment. This is why we didn't create any purchases
entry at the point of making payments. One way we could fulfill an order is to manually create the purchase entries from our Stripe dashboard. This is "manual" and hence prone to errors and very tedious. There is an automatic way to do this with Stripe, and it is called webhook
— a mechanism that allows one application to notify another application in real-time about specific events. Before we can fulfill orders, we want Stripe to notify us that payments have indeed been made (via its checkout.session.completed
or checkout.session.async_payment_succeeded
hooks). We need to patiently listen to this, and a good way is via an endpoint. To locally test this, kindly look into this
stripe guide
. You will need to create a destination (the url of your endpoint where you want stripe to send communications to, for this app, it'll be /api/webhooks/stripe
) and you will be prompted to select the hooks you want to listen to as well (I opted for only checkout.session.completed
). After this, a webhook secret will be generated for you. Copy it and save it as STRIPE_WEBHOOK_SECRET
in your environment variable. Locally, it will be generated when you first run stripe listen --events checkout.session.completed,checkout.session.async_payment_succeeded --forward-to localhost:5173/api/webhooks/stripe
(you have a lot of events to listen to!). To "trigger" an event locally, just run stripe trigger checkout.session.completed
in another terminal while the listen command is running in another. Your app should also be running. Here is our endpoint code:
import { STRIPE_WEBHOOK_SECRET } from "$env/static/private";
import { db } from "$lib/server/db";
import { purchases } from "$lib/server/db/schema";
import { stripe } from "$lib/server/payments";
import type { SessionMetadata } from "$lib/types/cart.js";
import { error, json } from "@sveltejs/kit";
import { eq, and } from "drizzle-orm";
export async function POST({ request }) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
error(400, "Missing stripe-signature header");
}
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
STRIPE_WEBHOOK_SECRET
);
if (
event.type === "checkout.session.completed" ||
event.type === "checkout.session.async_payment_succeeded"
) {
const session = event.data.object;
const metadata = session.metadata as unknown as SessionMetadata;
if (!metadata.books || !metadata.timestamp) {
error(400, "Missing required metadata");
}
const items = JSON.parse(metadata.books) as Array<{
bookId: string;
quantity: number;
}>;
const customerEmail =
session.customer_details?.email || session.customer_email || "unknown";
try {
const purchaseRecords = [];
// Check each item individually to avoid duplicates from webhook retries
for (const item of items) {
const existingPurchase = await db
.select()
.from(purchases)
.where(
and(
eq(purchases.stripeCheckoutSessionId, session.id),
eq(purchases.bookId, parseInt(item.bookId))
)
)
.limit(1);
if (existingPurchase.length === 0) {
purchaseRecords.push({
quantity: item.quantity,
customerEmail,
bookId: parseInt(item.bookId),
stripeCheckoutSessionId: session.id,
isCompleted: true,
});
}
}
if (purchaseRecords.length > 0) {
await db.insert(purchases).values(purchaseRecords);
console.log(
`✅ Created ${purchaseRecords.length} new purchase records for session ${session.id}`
);
} else {
console.log(
`ℹ️ All purchases already exist for session ${session.id}`
);
}
} catch (dbError) {
console.error("Database error creating purchases:", dbError);
error(500, "Failed to create purchase records");
}
}
return json({ received: true });
} catch (err) {
console.error("Webhook error:", err);
error(400, "Webhook Error");
}
}
To avoid fraudulent event notifications, we ensure the event is signed and verified. Unsigned and/or unverified events are outrightly rejected. After that, the event is constructed, and since we are only interested in checkout.session.completed
(covers most successful one-time payments) and async_payment_succeeded
(important for asynchronous payment methods (e.g., some bank transfers)) type, that's what we listen to. From there, we retrieve the details we sent previously during payment (from metadata
) and from it create purchases
entries. This is done in an idempotent way so that webhook retries by Stripe or anything won't create duplicates, which would result in losing money.
That's it! Thank you for your time!
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities to impact and learn, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and X . I am also an email away.