Recurring payments with SvelteKit + Stripe

Joshua Nussbaum - Jul 10 '23 - - Dev Community

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:

  1. Create a customer record in Stripe.
  2. Create a subscription record in Stripe. It will have a status of incomplete until payment is completed.
  3. Display a payment form using Stripe's <PaymentElement/> component.
  4. Handle the payment form submission and use Stripe's API to complete payment.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
})
Enter fullscreen mode Exit fullscreen mode

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(....)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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')
  }
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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_...
Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . . . . . . . . . . . . . .