Issue #12: Integrating Stripe for subscriptions management šŸ¤‘

Matching the user onboarding with the Stripe subscription lifecycle

This week has been fully focused on building, so this issue is going to be way more technical than usual. šŸ”Ø 

Brace yourself! šŸš€ 

There are many ways you can integrate Stripe for your SaaS, and today I will share how I tackled it myself.

Table of Contents

Usersā€™ onboarding and plans management

Letā€™s start by understanding how the onboarding is implemented. I discussed it already both here and here, but this issue will focus more on the technical side of it.

This is going to be a great documentation for myself as well once Iā€™ll face this again! šŸ˜‚ 

Beta phase and invite-only beta users

First of all, both the API and the web app have access to a flag named BETA_PHASE_ACTIVE that indicates whether the product is still in beta or not.

As you can imagine, this flag changes completely how the onboarding works.

When you go to the application during this phase, youā€™re redirected to the sign-up/in page. Once logged in you will be welcomed and blocked by a screen that says: ā€œOops! We're still working hard!ā€œ.

How can beta users start using the application then? šŸ¤” 

The database has a profiles table and each user has a corresponding entry. One of the columns is a flag is_enabled which is false by default. The web application simply shows the blocking screen whenever a user is not enabled.

Initially, I was enabling users manually one by one by asking them to ping me once they created their account, but it was adding friction to the onboarding. Hence I implemented a very simple mechanism to automatically enable beta users.

The invitation to a beta user is a URL that looks like: https://app.useechowords.com/?invitation-key=<redacted>

When the web app calls the API for creating the user, it also provides the invitation key. If the provided invitation key matches the expected one, then the newly created user is automatically enabled. šŸŽ‰ 

Ideally, youā€™d want to have unique invitation keys, but that was an overkill. šŸ˜… 

A beta user is also associated with a ā€œfakeā€ subscription which, in turn, is associated with a plan called ā€œBetaā€ that never expires. In the following sections, Iā€™ll cover more in-depth plans and subscriptions.

Generally available phase

This phase will start the day of the launch which has not been announced yet.

The BETA_PHASE_ACTIVE flag would be set accordingly and the user signing up would cause a profile to be created and enabled automatically. The other difference is that in this case no subscription is created, and the web app in case of no subscriptions associated with the profile, redirects to an onboarding page.

The onboarding page is simply a pricing table that is integrated with Stripe. Once a plan is chosen and the checkout is successful, a new subscription is created, and the user is given access. šŸŽ‰ 

In the ā€œSubscriptionsā€œ section the user will be able to see again the pricing table and manage their plan.

The ā€œSubscriptionā€œ section of the application

In the following sections, Iā€™ll explain in more detail the integration with Stripe.

Migrating beta users

During the ā€œgenerally availableā€ phase, beta users wonā€™t be impacted as soon as their subscription is valid. Before invalidating a subscription, which can be done manually by changing the ending date in the database, Iā€™ll reach out to each beta user.

I promised them a discount for providing feedback, so Iā€™ll share a special offer for each plan through a Stripe payment link. āœØ 

Once a subscription is canceled, only the ā€œSubscriptionā€œ page will be usable.

Representing subscriptions in the database

Letā€™s now see how Iā€™m representing a plan and a subscription in the database.

Thereā€™s a plans table and each row corresponds to a specific combination of plan and billing interval. For example, given a ā€œBasicā€ plan and two possible billing options, yearly and monthly, there would be two different rows in the plans table.

This is the schema of the plans table:

Schema of the ā€œplansā€ table

This means that unless I change pricing or add new plans, this table is going to be populated only once. This also includes a placeholder plan corresponding to the ā€œBetaā€ plan.

So now we have a profiles table and a plans table. The subscriptions table is the table that links them. There are different scenarios, the ones that will occur during the GA phase are:

  1. a user doesnā€™t have any subscription: a new user who joined during the GA phase and didnā€™t proceed with the onboarding,

  2. a user with a subscription that is linked to the ā€œBetaā€ plan: a beta user who accepted the invitation during the beta phase,

  3. a user with only an active subscription: a new user who joined and onboarded during the GA phase,

  4. a user with two subscriptions, but only one active: a beta user that has its first subscription expired and onboarded with a new one.

This is the schema of the subscriptions table:

Schema of the ā€œsubscriptionsā€ table

Syncing Stripeā€™s subscriptions lifecycle

Okay, so now I have a way to represent a plan and a subscription on my side, but how do they link to the corresponding Stripe objects? šŸ¤” 

If you checked the schema you might have noticed that both have a column called external_{plan,subscription}_id. Thatā€™s the id of the corresponding Stripe object! šŸ’” 

All the updates happening on Stripe are notified in real-time to a webhook that is exposed in the API. Itā€™s awesome that Stripe offers a way to test the webhook locally. šŸ¤© 

The Stripe API documentation is really well done! Iā€™m going to focus on 4 objects:

  1. Product,

  2. Price,

  3. Subscription,

  4. Payment Link.

The ā€œProductā€ is your offer or your plan. In my case, I have 3 products: ā€œBasicā€, ā€œProā€œ, ā€œPremiumā€.

Each ā€œProductā€ can have multiple ā€œPriceā€œs. In my case, for each product, I have 2 prices: a monthly one, and a yearly one.

The ā€œSubscriptionā€ can have multiple ā€œPriceā€s. In my case, each subscription can have only 1 ā€œPriceā€. In more complex scenarios a ā€œSubscriptionā€ could be associated with a recurring price and other stuff depending on the complexity of the offer.

The ā€œPayment Linkā€ is simply an object with a URL that shows the checkout for a specific ā€œPriceā€. Each CTA of the pricing table redirects to the corresponding ā€œPayment Linkā€.

Whatā€™s the relationship between these 4 objects and the 2 tables? The plans table loosely corresponds to the ā€œPriceā€ objects and the subscriptions table to the ā€œSubscriptionā€ objects.

How subscriptions work in Stripe

Iā€™ll describe only what is necessary for managing what I needed, for the rest you can read more about subscriptions here.

A subscription goes through multiple statuses, the ones that Iā€™m tracking are:

  • active,

  • past_due,

  • canceled.

A new user signs up, and a profile is created. Once the user picks a plan in the pricing table and successfully checks out, an checkout.session.completed event is sent to the webhook.

This event contains all the information that I need including the ā€œSubscriptionā€ and the ā€œPriceā€ object. At this point, I can create an entry in the ā€œsubscriptionsā€ table, and associate it to the user.

As I mentioned above, once an active subscription is attached to the user, the web app will make the user move forward by granting access. šŸš€ 

Every time a subscription is renewed an customer.subscription.updated event is sent to the webhook. This makes it possible to ensure that the status is aligned. One important thing is that upon renewal, the subscription object is always the same with the very same id.

Stripeā€™s Customer Portal for managing a subscription

If the payment fails, the status of the subscription sent to the webhook is past_due. This makes it easy to reflect this change in the subscriptions table and block users from using the platform. āŒ 

Stripe will automatically retry the payments according to some rules that you can set in the dashboard, once all the retries will fail, an customer.subscription.deleted event is sent to the webhook. This event will change the status to ā€œcanceledā€. At this point, thereā€™s no going back for that subscription, the user would need to start a new checkout. This is the case where Iā€™d have two subscriptions associated with the same user (excluding the case of beta users): an active one and a canceled one.

Users reconciliation

2 users successfully checked out, and you receive 2 checkout.session.completed events. How do you know which one corresponds to whom? šŸ˜… 

This is called ā€œreconciliationā€.

As mentioned above, each CTA in the pricing table redirects to the corresponding checkout, and the URL is given by the ā€œPayment Linkā€ Stripe object. This payment link can be extended by providing a query param client_reference_id.

Hence, each user will have the URLs specific to them with their own specific reference id. In my case, Iā€™m using a unique identifier of the user in the profiles table. This value is then available when you receive the checkout.session.completed event so itā€™s possible to associate the subscription to the correct user.

Creating the product plans in Stripe

I started simply by using the Stripe dashboard. This was the best way to get started, understand how everything is linked, and explore the events in the webhook, etc. Itā€™s great that Stripe has also a ā€œdevelopmentā€ mode where you can mess up however you want to explore and you also have a handy button to just reset it completely. šŸš€ 

Once I understood what I needed, I just didnā€™t want to rely on me manually copying to production the same products, prices, and whatever else. There should be a button to ā€œcloneā€ what you have from ā€œdevelopmentā€ to ā€œproductionā€, but still. šŸ˜… 

The thing is that billing is definitely something that I donā€™t want to fuck up. šŸ¤£ 

For this reason, I implemented a simple script that takes as input the definition of my ā€œProductā€s and ā€œPriceā€s and creates them in Stripe using the API.

Webhook async implementation

Stripe expects your webhook to respond fast. Otherwise, Stripe would consider it as a timeout, and youā€™d mess up with the events. Iā€™d rather not be in that situation. šŸ˜…

Yeah I know, I donā€™t have paying users yet, so Iā€™m overthinking things that I might not even bump into. Still, billing is really something that Iā€™d rather not ship buggy, even in the first launch. For this reason, Iā€™ve been super careful in implementing this and Iā€™m still covering all the possible scenarios (new users, beta users to migrate, etc.).

The requirement of being fast means that you should return a 2xx status code asap and all the operations on the database should be done async.

Pricing table component

Stripe offers a built-in pricing table that you can just embed as it is. But I donā€™t like it. šŸ˜‚ In particular, when you toggle from monthly to yearly it doesnā€™t show the monthly cost, but only the total yearly cost, so itā€™s harder to see the benefit of the yearly plan.

For this reason, I ended up implementing my own. My implementation toggles between monthly and yearly plans, and also shows available promos.

Pricing table component

DaVinci Resolve

ā

What does this has to do with all the rest?

You reading the heading of this section

DaVinci Resolve is a video editing tool. Iā€™m not really into this space, so it might not be the right way to describe it, let me just copy here the description from the website:

DaVinci Resolve is the worldā€™s only solution that combines editing, color correction, visual effects, motion graphics and audio post production all in one software tool!

Thinking about the launch, I started wondering whether a cool video teaser might be beneficial rather than just a loom video. šŸ˜… 

In my (little) spare time, Iā€™m also exploring this. šŸ˜ 

Conclusion

Uffā€¦ Writing this was quite some work. šŸ˜… 

This might not be the BEST solution as Stripe is HUGE, but it seems that itā€™s working well.

Funny enough this morning I was thinking ā€œI donā€™t know what to write today as Iā€™ve been in fully building modeā€œ. But in the end, thereā€™s no reason why I shouldnā€™t write about this stuff as well. šŸ˜Ž 

Iā€™m now making sure that all the scenarios are covered, and I also need to make sure that tracking the feature usage is still working. Last week I described how I implemented the tracking, but it has been implemented with some assumptions that turned out to be wrong once I started linking with Stripe objects. šŸ˜­ 

I already implemented somehow the Stripe integration for NextCommit, but it was poorly done, thatā€™s why I decided to start from scratch. This time, all this work will definitely be part of my own boilerplate.

The goals for the end of next week:

  1. be ready for the launch from an implementation perspective (test all the scenarios for the billing, plan the migration of beta users, fix the usage tracking, etc.),

  2. get all feedback from the beta users and prepare them for what will happen next,

  3. plan the work needed for launch and decide on when to do it.

I hope you enjoyed this weekā€™s updates! šŸ‘‹ 

If youā€™re interested in following my journey, make sure to subscribe or follow me on X/Twitter and LinkedIn!

Appendix

Personal branding

X

X Premium analytics

Beehiiv newsletter

Beehiiv Analytics

Reply

or to participate.