How to set up a subscription payment system for a SAAS with Stripe

By Hugo LassiègeMay 26, 202411 min read

Earlier this year, I used Lemonsqueezy to distribute Bloggrify paid templates.

In this case, it's a "one shot" purchase with a license key that allows you to use the template afterwards.

The difference between RssFeedPulse and Bloggrify's paid templates is that a SAAS often offers a subscription system.

A SAAS can have several models: pay-as-you-go, payment par fixed tier, payment linked to the number of users (or by features), and so on.

So there are two important elements:

  • knowing how to measure usage
  • being able to trigger payment at a fixed date

(And, of course, all the little intricacies of changing billing plans during the month, cancelling, upgrading, etc.).

Several services can help you manage some of these problems. But the best known is Stripe

Stripe has incredible documentation, but as Stripe's possibilities are quite extensive, it's pretty easy to get lost.

So here's how I set up the subscription mechanism on RssFeedPulse

And, before anyone remarks, yes, I did watch

which came out a few weeks ago.

But, there are some elements in its implementation that I find a bit annoying:

  • the construction of the pricing table, which is done with hard data that it copies from Stripe. This could be simplified.
  • the payment links, which are fine for one-shot payments, but not at all practical for subscriptions. (I'll explain below).
  • the obligation to ask the user to refresh the page to see his subscription changes. (I'll explain also below).

Stack overview

My stack is quite simple, as I've already explained in a previous post:

  • a backend built with Spring Boot (Kotlin)
  • a frontend built with Nuxt, deployed on cloudflare

So the general scheme looks like this:

stack RssFeedPulse

stack RssFeedPulse

Now here's the general workflow:

  • A user will see your pricing table on your site.
  • The user will select an offer and subscribe from the Stripe site.
  • Stripe will send the information to your application via a weebhook to inform you of the subscription.
  • Stripe will debit the card every month and inform you if the offer changes (subscription cancelled or modified).

subscription workflow

subscription workflow

Pricing table in Stripe

You could, of course, create the pricing table on your own site, then implement it again on Stripe. That's a bit of a shame, and you risk making mistakes. There's nothing more frustrating for a user than to see a price on the site and find that the price has changed at checkout.

But first, let's take a look at what a pricing table looks like:

pricing table

pricing table

You'll notice:

  • monthly and annual prices
  • list of thresholds by price plan
  • features included in each plan

And you can enter all this information on Stripe.

To do so, go to your Stripe dashboard in "Product catalog".
Here, for example, I have 4 products, with 2 variants (annual and monthly).

product catalog

product catalog

A product is, for example: Advanced plan

  • emails/month: 20,000
  • subscribers: 5,000
  • campaigns: unlimited

And for each product, you can have price variants. Here, I've made variants with monthly or annual direct debits.


I won't go into detail about product creation, as it's pretty trivial. However, as a little tip, you can use the notion of metadata to store the list of features. It's also possible to do this in "Marketing Feature list", but metadata are easier to manipulate afterwards, in my opinion.

Create your pricing table

A no-code solution

The first way to do this is to use Stripe's nocode solution. In the product catalog, you can create your pricing table.

Integration is then very simple: just insert a script and a webcomponent on the page

<script setup lang="ts">
    script: [
            src: '',
            async: true,
            defer: true,

The result is:

nocode pricing table

nocode pricing table

This is certainly the simplest solution, but it comes with a few limitations:

  • UI customization is very limited. (I don't care here).
  • You can only have 4 products and 3 price variants (in my case, I didn't mind).

The main constraint is that if the user clicks on the subscription link without first creating an account, you won't have created an account for them.

So the workflow will have to be :

  • the user subscribes to an offer
  • you process the event via a webhook and create the user at the same time
  • you send the user a magic link to log in, with the email used for the checkout.

Nevertheless, this is the practice I would recommend most of the time.

However, that's not what I did, because I wanted to force registration before subscription.


To be honest, at first I didn't understand how this feature worked either, so that's why I didn't use it ^^.

An API solution

The good news about Stripe is that if it's possible to get information via the dashboard, then it's also possible via API.

You can retrieve all the information you need to create this pricing table using the API.

Here's the Kotlin call:

    enum class PaymentRecurringPeriod {
        day, week, month, year

    data class Offers(
        val monthlyOffers: List<Offer>,
        val yearlyOffers: List<Offer>
    data class Offer(
        val id: String,
        val name: String,
        val description: String?,
        val marketingFeatures: List<String>,
        val price: Double,
        val currency: String,
        val recurringPeriod: PaymentRecurringPeriod
class StripeProductService(
    private val baseUrl: String,
    private val apiKey: String,
) {

    init {
        Stripe.apiKey = apiKey

    fun loadOffersFromStripe(): Offers {
        val productListParams = ProductListParams.builder()

        val priceListParams = PriceListParams.builder()

        val products = Product.list(productListParams).data
        val prices = Price.list(priceListParams).data

        val monthlyOffers = products.mapNotNull { product ->
            val price = prices.find { price -> price.product == && price.recurring?.interval == "month" }
                ?: return@mapNotNull null
            convertPriceToOffer(price, product)
        val yearlyOffers = products.mapNotNull { product ->
            val price = prices.find { price -> price.product == && price.recurring?.interval == "year" }
                ?: return@mapNotNull null
            convertPriceToOffer(price, product)

        val offers = Offers(monthlyOffers.sortedBy { it.price }, yearlyOffers.sortedBy { it.price })
        return offers
    private fun convertPriceToOffer(price: Price, product: Product): Offer {
        return Offer(
            id =,
            name =,
            description = product.description,
            marketingFeatures = { feature -> },
            price = price.unitAmount.toDouble() / 100,
            currency = price.currency,
            recurringPeriod = PaymentRecurringPeriod.valueOf(price.recurring?.interval ?: "day")

This code takes the information from Stripe, transforms it into a simpler format. And this information can be API'd back to the frontend.
This is exactly what I use for RssFeedPulse.

The big advantage is that on the landing page, I only offer the signup as an action link on the pricing table.

Now let's see how to send the user to the payment page, and how to allow him to modify his subscription.


In Marc Louvion's video, Marc uses payment links.
A payment link can be created on the Stripe dashboard, and Marc then stores the associated payment link for each product and price.

That's a lot of wasted time and a risk of error. Copy and paste is more difficult than you might think :)

The second problem with his method is that if the user decides to change his email address at the time of payment, Stripe will then send us an event concerning an unknown customer (since Stripe's email address will be different from our own).

Last but not least, the user may have several active subscriptions for the same email.

In short, we'll avoid all this by using checkout tunnels instead.

  • A checkout tunnel is used to create a payment session for a given user.
  • The tunnel is created on the fly, so there's no need to create tons of payment links from the Stripe dashboard.

Let's take a closer look.

If we inspect the link in my pricing table, each link goes to :

On the API side, here's what it looks like:

    fun createCheckoutSession(email: String, planId: String): String {
        return "redirect:" + stripeProductService.createCheckoutSession(email, planId)
    fun createCheckoutSession(email: String, priceId: String): String {
        val account = accountRepository.findByEmail(email) ?: throw BadParameterException("Account with $email not found")
        // create a session for this user, fixing the email address so that we force the user to use the same address on Stripe
        val params = SessionCreateParams.builder()

        val session = Session.create(params)
        return session.url

The user is then redirected to Stripe and cannot change his email address.


So no action has been taken in the Stripe dashboard, and the same email is guaranteed to be used everywhere!

Offer change or cancellation

Once registered, a user needs to be able to cancel or upgrade his subscription.

You could also use a checkout session, but the most appropriate here is to give the customer access to the "customer portal".

This is a page for cancelling or changing plans.

Once logged in, the user can consult his plan and click on "Manage".

Manage subscription

Manage subscription

The Manage button has a url that looks like this:

And the api this time looks like this:

    fun createPortalSession(email: String): String {
        return "redirect:" + stripeProductService.createPortalSession(email)
    fun createPortalSession(email: String): String {
        val account = accountRepository.findByEmail(email) ?: throw BadParameterException("Account with $email not found")
        val params = com.stripe.param.billingportal.SessionCreateParams.builder()
        val session = com.stripe.model.billingportal.Session.create(params)
        return session.url

You'll notice a subtlety here: I fill in the Stripe customerId when I create the portal. This allows the user to be authenticated directly on Stripe.

But if you've been following along so far, you might be wondering where this customerId comes from?

Well, it's because we haven't yet dealt with Stripe Webhooks.

Stripe webhook

A webhook is a Stripe API call made to your application to notify you of a change on Stripe.

This is how Stripe notifies you that a plan has been subscribed, paid, canceled, or modified.

So you need to have an API in place to handle calls from Stripe.

The webhook url is configured on the Stripe dashboard in the "Developer" section. And in production, you'll need to list the events you're interested in.

Here are the ones we're interested in:

  • customer.subscription.created
  • customer.subscription.deleted
  • customer.subscription.updated

Here's what the code looks like:

class StripeWebhook (
    private val apiKey: String,
    private val webhookSecret: String,

    private val accountSubscriptionService: AccountSubscriptionService

) {

    val logger: Logger = LoggerFactory.getLogger(
    init {
        Stripe.apiKey = apiKey


    fun handleWebhook(
        @RequestBody payload: String,
        @RequestHeader("Stripe-Signature") sigHeader: String
    ): ResponseEntity<String> {
        try {
            // check signature
            val event: Event = Webhook.constructEvent(
                payload, sigHeader, webhookSecret

            when (event.type) {
                "customer.subscription.created" -> {
                    val subscription = event.dataObjectDeserializer.deserializeUnsafe() as Subscription
                    val customerId = subscription.customer
                    val priceId =[0]

                    val customer = com.stripe.model.Customer.retrieve(customerId)
                    val email =

                    accountSubscriptionService.updateAccountPlan(email, priceId, customerId)
          "Subscription updated for account with email: $email and new plan: $priceId")
                "customer.subscription.deleted" -> {
                    val subscription = event.dataObjectDeserializer.deserializeUnsafe() as Subscription
                    val customerId = subscription.customer

                    val customer = com.stripe.model.Customer.retrieve(customerId)
                    val email =
          "Subscription deleted for account with email: $email")
                "customer.subscription.updated" -> {
                    val subscription = event.dataObjectDeserializer.deserializeUnsafe() as Subscription
                    val customerId = subscription.customer
                    val priceId =[0]

                    val customer = com.stripe.model.Customer.retrieve(customerId)
                    val email =
                    accountSubscriptionService.updateAccountPlan(email, priceId, customerId)
          "Subscription updated for account with email: $email and new plan: $priceId")
                else -> {
          "Unhandled event type: ${event.type}")
            return ResponseEntity.ok("Success")
        } catch (e: Exception) {
            logger.error("Error: ${e.message}")
            return ResponseEntity.badRequest().body("Error: ${e.message}")

You'll note that we retrieve the customerId at subscription to associate it with our user (the code for updateAccountPlan is not supplied, but it's quite trivial). This customerId allows us to positively identify the user who has performed a transaction on Stripe.

Ok, that's cool, but our user doesn't see his subscription active on the site. He has to refresh the page to see his active subscription.
Thankfully, no. We can do better.

Refresh page automatically

I use nuxt-auth-utils for frontend session management. This session contains the name of the plan subscribed to by the user.

Problem: if the user changes plan, the session is not updated.

You'll have to find a way around this.

To do this, I have a poller that checks every 10 seconds to see if the plan has changed.

const { session, fetch, clear, user } = useUserSession()
onMounted(() => {
    const fetchSession = async () => {
        try {
            if (!user.value) return
            const result = await $fetch<AccountDTO>('/api/auth/session')
            if (result) {
                if (user.value?.plan !== result.plan || user.value.blocked !== result.blocked) {
                    // This call is key.
                    // If the plan has changed, we fetch the session again
                    await fetch()
        } catch(err: any) {
            console.log('Request failed: ', err.message)

    setInterval(fetchSession, 10000)

This way, there's no need to ask the user to refresh the page. He'll see his subscription active in real time (or almost).


And no, I didn't want to bother with websocket or SSE for this. It's a bit overkill for this use case.

Polling every 10 seconds consumes resources. But RssFeedPulse isn't a site where you stay connected all day long. You just set your newsletter and log off, so I don't mind.


With this post, you now have a good understanding of :

  • how to create your pricing table
  • how to propose a payment tunnel to your users
  • how to allow your users to change their pricing plan
  • how to receive Stripe notifications

With APIs, you won't have anything hard-coded on your premises, except of course Stripe credentials.

Share this:

Written by Hugo Lassiège

Software Engineer with more than 20 years of experience. I love to share about technologies and startups

Copyright © 2024
  Powered by Bloggrify