When I built my last SaaS, I made one mistake: I launched without integrated payments.
At the time, it didn't seem like the biggest unknown compared to building features. I figured it could always be added later.
But it meant manually sending invoices and e-mail reminders and making phone calls about overdue payments. It wasn't that fun.
In hindsight, I learned that payment is the validation that the SaaS is working. Pushing it off just delays that important signal.
The good news is: it's easy to avoid the mistake I made. Stripe subscriptions can be added to your SvelteKit site in an afternoon.
Note: An alternative method is Stripe Checkout. Need help choosing?
Payment flow
For a customer to successfully pay for a subscription, the following steps are needed:
- Create a customer record in Stripe.
-
Create a subscription record in Stripe. It will have a
status
ofincomplete
until payment is completed. -
Display a payment form using Stripe's
<PaymentElement/>
component. - Handle the payment form submission and use Stripe's API to complete payment.
- Handle webhooks to provision the subscription.
Project configuration
First, add these dependencies to your SvelteKit app:
# `stripe` is the official node.js package.
# `svelte-stripe` is a community maintained wrapper.
pnpm install -D stripe svelte-stripe
Then define environment variables in .env
:
# Stripe secret key. Can be found at: https://dashboard.stripe.com/apikeys
SECRET_STRIPE_KEY=sk_...
# Stripe public key
# The PUBLIC_ prefix allows Svelte to include it in client bundle
PUBLIC_STRIPE_KEY=pk_...
# domain to use for redirections
DOMAIN=http://localhost:5173
You'll probably need to call the Stripe API from multiple places within your app, so it's a good idea to centralize the Stripe client:
// in src/lib/server/stripe.js
import Stripe from 'stripe'
import { env } from '$env/dynamic/private'
// export the stripe instance
export const stripe = Stripe(env.SECRET_STRIPE_KEY, {
// pin the api version
apiVersion: '2022-11-15'
})
Then, any time you want to access Stripe, import $lib/server/stripe
:
// import singelton
import { stripe } from '$lib/server/stripe'
// Do stuff with Stripe client:
//
// stripe.resource.list(....)
// stripe.resource.create(....)
// stripe.resource.update(....)
// stripe.resource.retrieve(....)
// stripe.resource.del(....)
Creating the customer
If your app has authentication, then you already have the customer's name and e-mail address.
If not, you can display a form to capture it:
<!-- in src/routes/checkout/+page.svelte -->
<h1>Checkout</h1>
<!-- posts to default form action -->
<form method="post">
<input name="name" required placeholder="Name" />
<input name="email" type="email" required placeholder="E-mail" />
<button>Continue</button>
</form>
Then on the server-side, create the Stripe customer and store the customer id in your database. In our case, since there's no database, we'll place it in a cookie instead:
// in src/routes/checkout/+page.server.js
import { stripe } from '$lib/stripe'
import { redirect } from '@sveltejs/kit'
export const actions = {
// default form action
default: async ({ request, cookies }) => {
// get the form
const form = await request.formData()
// create the customer
const customer = await stripe.customers.create({
email: form.get('email'),
name: form.get('name')
})
// set a cookie
cookies.set('customerId', customer.id)
// redirect to collect payment
throw redirect(303, '/checkout/payment')
}
}
Creating the subscription
Now that we have a customer, the subscription can be created on their behalf.
Subscriptions are keyed to a specific product and price. The product would be something like "Basic Plan" or "Enterprise Plan". Products can have multiple prices, for example the monthly price of the Basic Plan might be $20, but the yearly price is $100. Each are separate price IDs.
It's best to store pricing in the database or in a config file, but for demo purposes, we'll pass it as a param in the URL:
// in src/routes/checkout/payment/+page.server.js
import { stripe } from '$lib/stripe'
import { env } from '$env/dynamic/private'
export async function load({ url, cookies }) {
// pull customerId from cookie
const customerId = cookies.get('customerId')
// pull priceId from URL
const priceId = url.searchParams.get('priceId')
// create the subscription
// status is `incomplete` until payment succeeds
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [
{
price: priceId
}
],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent']
})
return {
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
returnUrl: new URL('/checkout/complete', env.DOMAIN).toString()
}
}
The endpoint returns two things:
-
clientSecret
: A token that represents the payment intent. It's needed by Stripe Elements. -
returnURL
: The URL of our thank-you page. The user will be redirected here once payment is successful.
Payment form
In the payment form, the <PaymentElement>
component will handle displaying all the payment options. This allows the customer to pay with a credit card or any of the supported payment methods in their region.
<!-- in src/routes/checkout/payment/+page.svelte -->
<script>
import { PUBLIC_STRIPE_KEY } from '$env/static/public'
import { onMount } from 'svelte'
import { loadStripe } from '@stripe/stripe-js'
import { Elements, PaymentElement } from 'svelte-stripe'
// data from server
export let data
// destructure server data
$: ({ clientSecret, returnUrl } = data)
// Stripe instance
let stripe
// Stripe Elements instance
let elements
// when component mounts
onMount(async () => {
// load the Stripe client
stripe = await loadStripe(PUBLIC_STRIPE_KEY)
})
// handle form submission
async function submit() {
// ask Stripe to confirm the payment
const { error } = await stripe.confirmPayment({
// pass instance that was used to create the Payment Element
elements,
// specify where to send the user when payment succeeeds
confirmParams: {
return_url: returnUrl
}
})
if (error) {
// handle error
console.error(error)
}
}
</script>
<h1>Payment</h1>
{#if stripe}
<form on:submit|preventDefault={submit}>
<!-- container for Stripe components -->
<Elements {stripe} {clientSecret} bind:elements>
<!-- display payment related fields -->
<PaymentElement />
</Elements>
<button>Pay</button>
</form>
{:else}
Loading Stripe...
{/if}
Completing the payment
Stripe will send the user to our "thank you" page and the payment intent ID is passed along as a query string.
The URL will look like this: http://localhost:5173/payment/complete?payment_intent=pi_xyz123
Using the payment intend ID, we can check on the payment's status and provision the account if it was successful.
// in src/routes/checkout/complete/+page.server.js
import { stripe } from '$lib/stripe'
import { redirect } from '@sveltejs/kit'
export async function load({ url }) {
// pull payment intent id from the URL query string
const id = url.searchParams.get('payment_intent')
// ask Stripe for latest info about this paymentIntent
const paymentIntent = await stripe.paymentIntents.retrieve(id)
/* Inspect the PaymentIntent `status` to indicate the status of the payment
* to your customer.
*
* Some payment methods will [immediately succeed or fail][0] upon
* confirmation, while others will first enter a `processing` state.
*
* [0] https://stripe.com/docs/payments/payment-methods#payment-notification
*/
let message
switch (paymentIntent.status) {
case 'succeeded':
message = 'Success! Payment received.'
// TODO: provision account here
break
case 'processing':
message = "Payment processing. We'll update you when payment is received."
break
case 'requires_payment_method':
// Redirect user back to payment page to re-attempt payment
throw redirect(303, '/checkout/payment')
default:
message = 'Something went wrong.'
break
}
return { message }
}
And a simple "thank you" page UI might look like:
<-- in src/routes/checkout/complete/+page.svelte -->
<script>
export let data
</script>
<h1>Checkout complete</h1>
<p>{data.message}</p>
Handling webhooks
There's no guarantee the user makes it to the "thank you" page.
It's possible they closed their browser or their internet cuts out because someone on their network is downloading too many torrents. That's why it's important to handle Stripe's webhooks too.
In our SvelteKit app, we can add an endpoint to handle webhooks:
// in src/routes/stripe/webhooks/+server.js
import { stripe } from '$lib/stripe'
import { error, json } from '@sveltejs/kit'
import { env } from '$env/dynamic/private'
// endpoint to handle incoming webhooks
export async function POST({ request }) {
// extract body
const body = await request.text()
// get the signature from the header
const signature = request.headers.get('stripe-signature')
// var to hold event data
let event
// verify the signature matches the body
try {
event = stripe.webhooks.constructEvent(body, signature, env.SECRET_STRIPE_WEBHOOK_KEY)
} catch (err) {
// warn when signature is invalid
console.warn('⚠️ Webhook signature verification failed.', err.message)
// return, because signature is invalid
throw error(400, 'Invalid request')
}
/* Signature has been verified, so we can process events
*
* Review important events for Billing webhooks:
* https://stripe.com/docs/billing/webhooks
*/
switch (event.type) {
case 'customer.subscription.created':
// Subscription was created
// Note: status will be `incomplete`
break
case 'customer.subscription.updated':
// Subscription has been changed
break
case 'invoice.paid':
// Used to provision services after the trial has ended.
// The status of the invoice will show up as paid. Store the status in your
// database to reference when a user accesses your service to avoid hitting rate limits.
break
case 'invoice.payment_failed':
// If the payment fails or the customer does not have a valid payment method,
// an invoice.payment_failed event is sent, the subscription becomes past_due.
// Use this webhook to notify your user that their payment has
// failed and to retrieve new card details.
break
case 'customer.subscription.deleted':
if (event.request != null) {
// handle a subscription canceled by your request
// from above.
} else {
// handle subscription canceled automatically based
// upon your subscription settings.
}
break
default:
// Unexpected event type
}
// return a 200 with an empty JSON response
return json()
}
To test the webhook in development mode, a proxy is needed to tunnel events to our local machine. Since we're using localhost and not on the public internet.
Fortunately, Stripe's CLI can do just that:
# login to your account
stripe login
# forward webhook events to your localhost
stripe listen --forward-to localhost:5173/stripe/webhooks
stripe listen
will print out a "Webhook Secret" (it starts with whsec_...
). Make sure to add that to the .env
and restart the dev server.
# in .env
SECRET_STRIPE_WEBHOOK_KEY=whsec_...
That's it folks. You now have subscriptions in your SvelteKit app!
For the complete source code, see:
https://github.com/joshnuss/sveltekit-stripe-subscriptions
Conclusion
Subscriptions are the ultimate validation for your SaaS app. It's the proof there's demand for your idea.
Without it, you'd have to resort to a more manual process and hoping people pay on time. It's better to get that validation as early as possible.
Even if people don't buy, it's better to know that early, rather than hope it may happen later. At least then you can start pivoting.
Happy hacking!