DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 964,423 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Cover image for Enable your SaaS users to access paid features with webhooks
CJ Avilla for Stripe

Posted on • Updated on

Enable your SaaS users to access paid features with webhooks

When a user pays for your service, then you want to give them access to paid features. Giving access is sometimes called "provisioning". Provisioning roles or permissions happens after creating a user and setting up authentication. Authentication tells us that a user is who they say they are. Now we're going to add authorization which tells that a user is allowed to access or perform a given action.

For the purposes of this article, we'll call "authenticated content" the pages and features that users can access if they’re logged in and "authorized content" the stuff users can see or do because they're both logged in and have permission to do so. Since we're talking about subscriptions and payments, we'll also assume that if someone is paying for something they become authorized. Your business model may be more complex and involve accounts with several users collaborating with different roles that define their authorization level.

Authentication review

We’re going to build our payment authorization system like a typical authentication flow.

In Rails, it’s common to have a before_action callback that ensures a user is logged in. This before_action is applied on the controllers that handle requests for authenticated content. If you’re not using Ruby on Rails, you’ll find something similar in your web framework like the useUser hook in Remix or the @login_required decorator in Django.

# Define a method to be used as a callback on a base class
class ApplicationController < ActionController::Base
  def authenticate_user!
    if !logged_in?
      redirect_to "/login"
    end
  end
end

# Use the callback so that users cannot access any of the /account resources
# without being logged in.
class AccountsController < ApplicationController
  before_action :authenticate_user!
end
Enter fullscreen mode Exit fullscreen mode

Think of it as a step on the server that asks "do I know who the current user is?" and if so, lets the request through. If not, the request is halted, and the user is redirected to a login page.

Depending on the authentication mechanism, the server will know there is a current user by looking at the session or cookies and then confirming there's matching data in the database.

Authorizing access for paid users will work similarly. In order to allow a user to see paid content we need to check the status of their subscription.

Again, we're faced with a decision: store some state in our database, or hit the Stripe API when we need to check if a user has access.

Option A: Store some subscription state in the database

In addition to the Stripe Customer ID, I recommend that you also store the Stripe Subscription ID, Price ID, and Subscription Status along with the user in the database. I like to store and display the Product name and description to the customer. For metered billing, it can also be handy to have the ID of the specific subscription item related to the metered price so that you can easily create UsageRecords.

When the Subscription is first created, a customer.subscription.created webhook event notification will be sent to your webhook handler. Since we're storing the user's ID in the Subscription metadata, we can find the user by ID and set the Subscription ID, Price ID, and Subscription Status.

case event.type
when 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted'
  subscription = event.data.object # contains a Stripe::Subscription object
  user = User.find_by(stripe_customer_id: subscription.metadata.user_id)
  user.update(
    stripe_subscription_id: subscription.id,
    stripe_subscription_status: subscription.status,
    stripe_price_id: subscription.items.data.first.id
  )
end
Enter fullscreen mode Exit fullscreen mode

Do you want to support multiple subscriptions? You could consider a second active subscription a signal to cancel the old subscription. The bottom line is, if you're keeping this state in your database, you'll need to handle these edge cases.

  • Pros
    • Efficient if subscription status needs to be checked many times while a user is logged in (maybe every page?)
  • Cons
    • Increases state management overhead

When the status of the Subscription is stored on the user, it's trivial to check if the subscription is active or trialing.

class User < ApplicationRecord
  def subscribed?
    ['trialing', 'active'].include?(stripe_subscription_status)
  end
end
class ApplicationController < ActionController::Base
  #...
  def require_active_subscription!
    if !current_user.subscribed?
      redirect_to '/pricing'
    end
  end
end
class BlogsController < ApplicationController
  before_action :require_active_subscription!
  #...
end
Enter fullscreen mode Exit fullscreen mode

Similarly, we can add filters for given levels. Assuming the enterprise features are a superset of the startup features and more, your solution might look like:

class User < ApplicationRecord
  #...
  def startup?
    subscribed? && stripe_product_id == 'prod_abc123'
  end
  def enterprise?
    subscribed? && stripe_product_id == 'prod_xyz232'
  end
end
class ApplicationController < ActionController::Base
  #...
  def require_startup!
    if !current_user.startup? || !current_user.enterprise?
      redirect_to '/pricing'
    end
  end

  def require_enterprise!
    if !current_user.enterprise?
      redirect_to '/pricing'
    end
  end
end
class BlogsController < ApplicationController
  before_action :require_startup!, only: [:index]
  before_action :require_enterprise!, only: [:create, :update, :destroy]
  #...
end
Enter fullscreen mode Exit fullscreen mode

Option B: Hit the API

Each time we need to check the status of a Subscription for a given user, we could make a request to the Stripe API to ask for the list of the customer's active subscriptions.

subscriptions = Stripe::Subscription.list({
  customer: current_user.stripe_customer_id,
})
subscriptions.any? {|s| s.status == 'trialing' || s.status == 'active'}
Enter fullscreen mode Exit fullscreen mode

Again, your answer depends on your business model. If you are shipping a subscription box once per month and need to check the status once per month per customer, then option B could simplify the integration and use Stripe as the source of truth. Option B requires very infrequent checks to the API out of band (when the customer is not browsing). If you have multiple levels for your SaaS or you plan to check the status frequently, then I recommend Option A.

Additional considerations

Assuming we’ve selected an approach to handling access provisioning, one other consideration is displaying context about the Subscription to the end user. At a minimum we’ll likely want to display that status of the Subscription, and we might also want to show the name of the plan level, the quantity of seats, or when the subscription is due to renew.

One best practice for handling webhook notifications related to Stripe Checkout and Subscriptions is to use the expand feature of the API to retrieve additional data we’ll want in the database.

Here’s an example for handling a webhook event for checkout.session.completed when using the embedded pricing table. Recall that when using the embeddable pricing table, we’re unable to pass an existing customer reference, so we lazily create one based on the client_reference_id from the session (passed into the pricing table web component on the pricing page).

Notice that when we re-retrieve the Subscription, we pass expand: ['items.data.price.product'] that will return the Subscription data from the API with the related line item’s prices and products. That way we can display the name of the product to the customer later without needing to re-retrieve the Subscription from the API.

def handle_checkout_session_completed(event)
  checkout_session = event.data.object
  customer = Customer.find_by(stripe_id: checkout_session.customer)
  if customer.nil?
    user = User.find_by(id: checkout_session.client_reference_id)
    customer = Customer.create!(
      user: user,
      stripe_id: checkout_session.customer
    )
  end

  subscription = Stripe::Subscription.retrieve(
    id: checkout_session.subscription,
    expand: ['items.data.price.product']
  )

  Subscription.create!(
    customer: customer,
    stripe_id: subscription.id,
    stripe_price_id: subscription.items.data.first.price.id,
    stripe_product_name: subscription.items.data.first.price.product.name,
    status: subscription.status,
    quantity: subscription.items.data.first.quantity
  )
end
Enter fullscreen mode Exit fullscreen mode

Subscriptions can change in several way -- here’s how I typically handle the customer.subscription.updated event notifications so that we keep track of the new subscription status, price ID, product name, and quantity (think number of seats!).

def handle_subscription_updated(event)
  subscription = Stripe::Subscription.retrieve(
    id: event.data.object.id,
    expand: ['items.data.price.product']
  )
  # lookup the subscription in the database:
  sub = Subscription.find_by(stripe_id: subscription.id)
  sub.update!(
    status: subscription.status,
    stripe_price_id: subscription.items.data.first.price.id,
    stripe_product_name: subscription.items.data.first.price.product.name,
    quantity: subscription.items.data.first.quantity
  )
end
Enter fullscreen mode Exit fullscreen mode

Next steps

In the next article of this series, you’ll learn how to manage the customer lifecycle using the customer portal. You can configure it to allow customers to update payment methods on file, change their plan, and view their invoice history. The customer portal pairs perfectly with all solutions for starting a Subscription and removes a ton of boilerplate code you’d otherwise need to write for billing management.

About the author

CJ Avilla

CJ Avilla (@cjav_dev) is a Developer Advocate at Stripe, a Ruby on Rails developer, and a YouTuber. He loves learning and teaching new programming languages and web frameworks. When he’s not at his computer, he’s spending time with his family or on a bike ride 🚲.

Top comments (0)

🌚 Browsing with dark mode makes you a better developer.

It's a scientific fact.