About: Software Engineer experienced in building applications using Python, Rust (Web), Go, and JavaScript/TypeScript. I am actively looking for new job opportunities.
Location:
USA
Joined:
Jun 25, 2020
Integrating Stripe with SvelteKit: Dynamic Pricing and Payment Security
Publish Date: May 28
1 4
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.
A simple demo app that integrates Stripe with SvelteKit
Project Specification: Simple Digital Books App with SvelteKit & Stripe
1. Project Goal
To build a minimalist digital books application using SvelteKit to demonstrate Stripe integration for purchasing digital goods. 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.
2. Core Features
2.1. Book Catalog
Display a list of available digital books.
Each book entry should show:
Title
Author
Cover Image (URL)
Price
Allow users to view more details for a single book (e.g., a longer description).
2.2. Shopping Cart
Users can add books from the catalog to a shopping cart.
Users can view the contents of their shopping cart.
Users can remove books from their shopping cart.
The cart should display the subtotal and total price.
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 asked Which 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";exportconstbooks=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 issuespriceInCents:integer("price_in_cents").notNull(),});exportconstpurchases=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 behaviorpurchasedAt: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
*/exportconstformatMoney=(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}constvalidAmount=(Number(amount)||0)/100;// Convert cents to dollarsreturnvalidAmount.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)thrownewError("DATABASE_URL is not set");if (!process.env.DATABASE_AUTH_TOKEN)thrownewError("DATABASE_AUTH_TOKEN is not set");exportdefaultdefineConfig({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*asschemafrom"./schema";import{env}from"$env/dynamic/private";if (!env.DATABASE_URL)thrownewError("DATABASE_URL is not set");if (!env.DATABASE_AUTH_TOKEN)thrownewError("DATABASE_AUTH_TOKEN is not set");constclient=createClient({url:env.DATABASE_URL,authToken:env.DATABASE_URL.startsWith("file:")?undefined:env.DATABASE_AUTH_TOKEN,});exportconstdb=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";importtype{Book}from"$lib/server/db/schema";importtype{CartItem}from"$lib/types/cart";import{formatMoney}from"$lib/utils/helpers";// Load cart from localStorage if availablefunctionloadCartFromStorage():CartItem[]{if (!browser)return[];try{constsaved=localStorage.getItem("digibooks-cart");returnsaved?JSON.parse(saved):[];}catch{return[];}}// Save cart to localStoragefunctionsaveCartToStorage(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 runesfunctioncreateCartState(){letitems=$state<CartItem[]>(loadCartFromStorage());functionaddItem(book:Book){constexistingItem=items.find((item)=>item.book.id===book.id);if (existingItem){existingItem.quantity+=1;}else{items.push({book,quantity:1});}saveCartToStorage(items);}functionremoveItem(bookId:number){items=items.filter((item)=>item.book.id!==bookId);saveCartToStorage(items);}functionupdateQuantity(bookId:number,quantity:number){if (quantity<=0){removeItem(bookId);return;}constitem=items.find((item)=>item.book.id===bookId);if (item){item.quantity=quantity;saveCartToStorage(items);}}functionclear(){items=[];saveCartToStorage(items);}functiongetTotalItems():number{returnitems.reduce((total,item)=>total+item.quantity,0);}functiongetTotalPrice():number{returnitems.reduce((total,item)=>total+item.book.priceInCents*item.quantity,0);}functiongetFormattedTotal():string{returnformatMoney(getTotalPrice());}return{getitems(){returnitems;},addItem,removeItem,updateQuantity,clear,getTotalItems,getTotalPrice,getFormattedTotal,};}// Create a singleton instanceexportconstcartState=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:
importtype{Book}from'$lib/server/db/schema';// Cart item interfaceexportinterfaceCartItem{book:Book;quantity:number;}exportinterfaceSessionMetadata{customerEmail?:string;userId?:string;// Optional user ID for authenticated userstimestamp:string;// ISO string for when the session was createdbooks:string;// Array of book IDs and their quantitiesitemCount?:string;// Number of items in the carttotalAmount?:string;// Total amount in cents as a string}exportinterfacePaginationMetadata{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
*/exportconstaddToCart=(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,typeSQL,like,asc,desc,count}from"drizzle-orm";importtype{PageServerLoad}from"./$types";exportconstload:PageServerLoad=async ({url})=>{constsearchQuery=url.searchParams.get("search");constsortBy=url.searchParams.get("sort")||"featured";constpage=parseInt(url.searchParams.get("page")||"1");constlimit=8;constoffset=(page-1)*limit;constwhereClause:SQL[]=[];// Apply search filterif (searchQuery){whereClause.push(like(books.title,`%${searchQuery}%`),like(books.author,`%${searchQuery}%`));}constorderByClause:SQL[]=[];// Apply sortingswitch (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:// featuredorderByClause.push(asc(books.id));break;}// Execute both queries in parallelconst[countResult,allBooks]=awaitPromise.all([// Count querydb.select({count:count()}).from(books).where(whereClause.length>0?or(...whereClause):undefined).then((result)=>result[0]),// Books query with paginationdb.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,};};
Svelte automatically generates $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:
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";importtype{PageServerLoad}from"./$types";import{db}from"$lib/server/db";import{books}from"$lib/server/db/schema";import{eq}from"drizzle-orm";exportconstload:PageServerLoad=async ({params})=>{constbook=awaitdb.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";importtype{PageServerLoad}from"./$types";exportconstload:PageServerLoad=async ({url})=>{constemail=url.searchParams.get("email");if (!email){return{purchases:[],email:null,};}try{// Fetch purchases with book details for the given emailconstuserPurchases=awaitdb.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";importtype{CartItem,SessionMetadata}from"$lib/types/cart";importStripefrom"stripe";exportconststripe=newStripe(STRIPE_SECRET_KEY);exportasyncfunctioncreateCheckoutSession({items,successUrl,cancelUrl,customerEmail,metadata,}:{items:CartItem[];successUrl:string;cancelUrl:string;customerEmail?:string;metadata?:SessionMetadata;}){try{// Convert items to Stripe line itemsconstlineItems=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,}));constsessionParams: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 providedif (customerEmail){sessionParams.customer_email=customerEmail;}constsession=awaitstripe.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:errorinstanceofError?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 $\frac{1}{100}$ 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:
With that, we can now implement the form action that uses this function:
import{createCheckoutSession}from"$lib/server/payments";importtype{CartItem}from"$lib/types/cart";import{fail,redirect}from"@sveltejs/kit";importtype{Actions}from"./$types";exportconstactions:Actions={checkout:async ({request,url})=>{constformData=awaitrequest.formData();constcartItems=JSON.parse(formData.get("cartItems")asstring)asCartItem[];constcustomerEmail=formData.get("email")asstring;// Validate cart itemsif (!cartItems||!Array.isArray(cartItems)||cartItems.length===0){returnfail(400,{error:"Cart is empty",});}constresult=awaitcreateCheckoutSession({items:cartItems,customerEmail:customerEmail||undefined,metadata:{userId:"guest",// Replace with actual user ID if authenticatedtimestamp:newDate().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){returnfail(500,{error:result.error||"Failed to create checkout session",});}// Redirect to Stripe checkoutredirect(303,result.url!);},};
We simply retrieve the needed data from the form, slightly process it, and send it to Stripe.
Beware of putting a redirect in try...catch:
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):
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";importtype{SessionMetadata}from"$lib/types/cart.js";import{error,json}from"@sveltejs/kit";import{eq,and}from"drizzle-orm";exportasyncfunctionPOST({request}){constbody=awaitrequest.text();constsignature=request.headers.get("stripe-signature");if (!signature){error(400,"Missing stripe-signature header");}try{constevent=stripe.webhooks.constructEvent(body,signature,STRIPE_WEBHOOK_SECRET);if (event.type==="checkout.session.completed"||event.type==="checkout.session.async_payment_succeeded"){constsession=event.data.object;constmetadata=session.metadataasunknownasSessionMetadata;if (!metadata.books||!metadata.timestamp){error(400,"Missing required metadata");}constitems=JSON.parse(metadata.books)asArray<{bookId:string;quantity:number;}>;constcustomerEmail=session.customer_details?.email||session.customer_email||"unknown";try{constpurchaseRecords=[];// Check each item individually to avoid duplicates from webhook retriesfor (constitemofitems){constexistingPurchase=awaitdb.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){awaitdb.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");}}returnjson({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.
We can!