Andy from Webcrunch

Subscribe for email updates:

Portrait of Andy Leverenz
Andy Leverenz

February 7, 2022

Last updated November 5, 2023

Stripe Checkout and Stripe Customer Portal Ruby on Rails Tutorial

Accepting payments has become easier than ever. Stripe is a platform that enables so many entrepreneurs to branch out and make their way to building products, tools, and services that can be paid for digitally and in many varied formats.

While Stripe Checkout is simple at face, there are a few important steps to take when adding it to a Ruby on Rails app for maximum security and compatibility. This is a concise guide on how to add Stripe Checkout and Stripe Customer Portal to your Ruby on Rails app. These two offerings complete the circle of a complete billing solution that's nearly drop-in.

A summary of what's involved here:

Stripe Checkout - Offer subscriptions or fixed-price products for sale in a convenient and secure checkout UI.

Stripe Customer Portal - Give your customers a place to easily manage their current subscription(s).

Goals

For this guide, I'll be building a small premium content-based app for purely demonstration purposes. To get access to more content you would pay a subscription fee in a recurring fashion. One could argue that Ruby on Rails is overkill for this concept but think of it as room to grow πŸ˜‰.

A Preface

In this guide, I use an application template called kickoff_tailwind. It saves me time when doing these types of tutorials though often the defaults it provides are overkill. If you look under the surface in this guide you'll notice another gem called pay-rails is in use.

We won't be leveraging that gem for this tutorial but I wanted to mention it to avoid any confusion since it comes pre-installed within the template. The gem provides more of a customized approach towards billing in a given rails app. If you need more control that'd be a great avenue to explore.

My goal with this tutorial is to use built-in tools straight from Stripe that allow you to get billing done in less than a day.

Start with Stripe

To get started you will need a Stripe account. In a "testing" state you won't need to have your account activated. Eventually, to accept real payments, you will need to activate your account (provide your billing information).

Head to stripe.com to create an account and circle back here.

API keys

Stripe API Keys  - Test mode

Stripe's API is one of the best out there. It's versioned based on your account so you can upgrade to a new version whenever you decide. Accessing the API is possible through a set of API keys issued when your account gets created.

As of right now, Stripe issues a private and public key. Your public key can be public, (as if that wasn't already obvious) but your private key should be treated as a secure password. Be sure to never commit your private key to version control unless it's encrypted (Encryption is something modern versions of Ruby on Rails helps with and that we will utilize coming up).

Once you find your API keys we can kick off the app build ✨.

Starting the project

We'll make use of Rails 7 (gem install rails should get you the latest version. You'll need Ruby version 2.7.0 or newer.) for this tutorial. With Rails 7 comes some new defaults which are no longer reliant on Webpack. Read more about the shift here.

Generate a new app:

$ rails new stripe_checkout_portal_tutorial -m kickoff_tailwind/template.rb

This command creates a new app and locally references an application template I created to help speed up some default configurations I tend to use. You're free to skip but there might be some discrepancies in your results as you follow along here.

I cloned that application into my current workspace. Assuming you have a similar path you can copy and paste the code above. You may need to supply something more elaborate.

From here I'll cd into stripe_checkout_portal_tutorial and run bin/dev.

If this doesn't work for you, it probably means you don't have the foreman gem installed. Be sure to run gem install foreman before continuing.

This should fire up two processes which you can find in Procfile.dev:

  • The infamous Rails server
  • Tailwind CSS

Navigating to localhost:3000 should render my template's index page if you're making use of it. If not you should see the default rails hello world page.

Configuring your application to use Stripe

Stripe has both a front-end and back-end (server-side) responsibility. We will need to leverage the Stripe Ruby gem to make API calls from the server-side portion of the app. We also need to use the Checkout API to display things correctly on the front-end. Let's get started.

Install the Ruby gem

If you're not using my template be sure to add the line below to the Gemfile inside your app making sure to keep it outside any development or test groups. However, if you are using the template, consider this step complete.

# Gemfile

gem 'stripe'

Then run:

bundle install

Stripe CLI

A newish useful tool from Stripe is their CLI. This is great for listening for events locally on your machine as opposed to the old way of having to trigger test events from your Stripe account. We'll make more use of this in a bit but for now, we need to install the CLI.

I'm on a mac and use homebrew.

brew install stripe/stripe-cli/stripe

Adding a checkout button to a view

With the Stripe gem installed we can proceed to add a button to a given view that will be responsible for launching the Stripe Checkout experience.

For most products and services offered online what you sell is typically one-off or subscription-based. Stripe Checkout makes this super quick to get going with but is often limited if you need to accept variable-style amounts (i.e. pay what you want).

For more control, you could swap Stripe Checkout for Stripe Elements instead but I've found checkout is pretty flexible in most cases.

Create a pricing page

Let's create a basic pricing page and add a single subscription-based product to it.

I'll first create the rails controller and basic action.

rails g controller static pricing --skip-routes --skip-stylesheets

This command will generate a few files. I passed a couple of flags to skip the default routing and stylesheets the generator normally generates.

create  app/controllers/static_controller.rb
invoke  erb
create    app/views/static
create    app/views/static/pricing.html.erb
invoke  test_unit
create    test/controllers/static_controller_test.rb
invoke  helper
create    app/helpers/static_helper.rb
invoke    test_unit

I'll add some enhanced routing to denote how the pricing page should render.

# config/routes.rb
require 'sidekiq/web'

Rails.application.routes.draw do

    authenticate :user, lambda { |u| u.admin? } do
      mount Sidekiq::Web => '/sidekiq'
    end


  devise_for :users

  # Add 3 these lines below
  scope controller: :static do
    get :pricing
  end

  root to: 'home#index'
end

This is the state of my current config/routes.rb file. It has some extra fluff from the template as previously stated. You don't need to be too concerned about what's going on here. If you are new to routing, check out this guide I created for a crash course on the basics.

Add a link to pricing in the main navigation

I want to add a link to the pricing page we just generated which takes the place of the "Basic Link" found in the template.

Swap this:

<!-- app/views/shared/_navbar.html.erb-->
<%= link_to "Basic Link", "#", class: "p-3 hover:bg-gray-50 transition ease
duration-300 rounded-lg text-center focus:bg-gray-100 lg:inline-block block" %>

For this:

<!-- app/views/shared/_navbar.html.erb-->
<%= link_to "Pricing", pricing_path, class: "p-3 hover:bg-gray-50 transition
ease duration-300 rounded-lg text-center focus:bg-gray-100 lg:inline-block
block" %>

Now we have a quick way to hit the pricing page.

Adding pricing page front-end code

I added some mocked-up code to mimic what a subscription to a content-based offering might include. That lives inside app/views/static/pricing.html.erb. It's very basic so don't judge me too hard.

<!-- app/views/static/pricing.html.erb-->

<div class="max-w-xl mx-auto p-8 rounded-xl shadow-xl mt-6">
  <div class="h-[200px] bg-gray-100 overflow-hidden mb-6 rounded-lg">
    <img
      src="https://source.unsplash.com/random/800x400"
      class="bg-gray-100 object-cover rounded-lg"
    />
  </div>
  <h1 class="font-black text-3xl mb-2">Subscribe for access</h1>
  <p class="text-lg text-gray-700 mb-3">
    You'll get never before seen content, an extra article per week, and access
    to our private community.
  </p>
  <div class="grid grid-cols-2 gap-6">
    <button
      class="px-3 py-4 bg-teal-600 block w-full text-center rounded font-bold text-white shadow-sm"
    >
      Subscribe for $9/mo
    </button>
    <button
      class="px-3 py-4 bg-indigo-600 block w-full text-center rounded font-bold text-white shadow-sm"
    >
      Subscribe for $90/yr
    </button>
  </div>
</div>

The main component that will hook into is the button element. In a future step, we'll make these more oriented for Rails specifically.

Back to Stripe

Depending on your offering you can create products and prices in Stripe's dashboard.

Stripe product info creation

Here I'll create a month and an annual option. To be generic I called the product Premium Access. Our monthly price will be $9 and our annual price will be $90 (two months free to entice more yearly subscriptions πŸ”₯).

Stripe created products

To summarize, we have a single product with two prices.

  • Premium Access - Monthly @ $9/mo
  • Premium Access - Annual @ $90/yr

You'll need the price IDs generated by Stripe to do the bulk of the work ahead.

Modeling Subscriptions

To keep track of our subscriptions in-app we can create a subscriptions table in the database and associate Stripe data with that subscription and the user subscribing. Note: This section assumes your app already contains a User model. We have one thanks to the bundled Devise gem within my template.

Let's generate a Subscription model.

It will have the following columns:

  • plan - string (The plan will allow us to assign which product the user is actively subscribed to.)
  • customer_id - string (Allows us to associate a customer to a subscription for easier retrieval)
  • subscription_id - string (The id given to us by stripe for each newly generated subscription.)
  • status - string (Determines the given state a subscription is in. Can be [incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid]).
  • interval - string (The billing interval of the given plan)
  • current_period_end - datetime (When the subscription ends next)
  • current_period_start - datetime (When the subscription renews next)
  • 'user-integer` - a way to tie a user to a subscription in our app
rails generate model Subscription plan_id:string customer_id:string user:references status:string current_period_end:datetime current_period_start:datetime interval:string subscription_id:string
invoke  active_record
create    db/migrate/20210627172041_create_subscriptions.rb
create    app/models/subscription.rb
invoke    test_unit
create      test/models/subscription_test.rb
create      test/fixtures/subscriptions.yml

Inside the migration file we have the following:

class CreateSubscriptions < ActiveRecord::Migration[7.0]
  def change
    create_table :subscriptions do |t|
      t.string :plan
      t.string :customer_id
      t.string :subscription_id
      t.references :user, null: false, foreign_key: true
      t.string :status
      t.string :interval
      t.datetime :current_period_end
      t.datetime :current_period_start

      t.timestamps
    end
  end
end

Let's migrate the change into the database:

rails db:migrate

Generating the new model also created a subscription.rb file in app/models and inside it has the following:

# app/models/subscription.rb

class Subscription < ApplicationRecord
  belongs_to :user
end

This effectively associates a user to a Subscription. We need to assign the User model manually now. A user could have either one subscription at a time or many depending on how you want your app to function. I think allowing multiple subscriptions is fine personally but you be the judge. With that in mind, we'll use a has_many association on the user model.

# app/models/user.rb

class User < ApplicationRecord
  has_person_name

  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :subscriptions, dependent: :destroy # add this line <<--
end

You can ignore the has_person_name and devise lines for this part. The key thing to add is the has_many :subscriptions bit. You'll notice I also appended the dependent: :destroy suffix to that line of code. This means that if ever a user deletes their account, the associated subscription will also be deleted.

To be more thorough here you probably want to add a callback function BEFORE a user is deleted to also ping the Stripe API. This is to ensure their subscription is canceled on Stripe's end and they don't get charged again.

Add stripe_id to users

We need to associate the stripe_id to a given User should they subscribe. Our current users' table doesn't have this column. Let's add it now:

rails generate migration add_stripe_id_to_users stripe_id:string

Even though IDs are often integers, a third-party API could update at some point. Doing so means keeping our options open. If we were to use an integer type that binds us to numbers only so opting for a string helps make this more flexible.

rails db:migrate

Create a checkout session

Back in the old days before SCA (strong customer authentication) was a requirement for some countries, you could generate a token on the front end, pass it to the back end, and proceed to charge a given customer's credit card.

Due to security and identity concerns of late, there's a newer protocol now being enforced with newer versions of Stripe's API.

The new protocol requires backend session generation to occur first. You can then pass data back to the front-end and complete checkout. The front end captures the card data, matches a secret key id, and then is shipped back to Stripe via serverside call. It sounds complicated but it's not the hardest thing to code. Let's dig in.

Let's first create a route for our Stripe Checkout instance.

# added to config/routes.rb
namespace :purchase do
  resources :checkouts
end

resources :subscriptions

Here I declared a namespace for organizational purposes (feel free to customize). The URL would look like /purchase/checkouts/new when we create a new Stripe Checkout session (coming up). With this routing added we need the associated controller to make this all work.

The end goal is to be able to submit the plan a customer chooses to an endpoint that will eventually subscribe that customer using Stripe's Checkout UI and API.

The Checkouts controller

We need some controller logic to handle each request now. To do this let's create one manually called checkouts_controller.rb. Because of the namespace, we introduced this will live within a module (or folder) in app/controllers called purchase

Our controller without any logic looks like this:

# app/controllers/purchase/checkouts_controller.rb
class Purchase::CheckoutsController < ApplicationController
  before_action :authenticate_user!

  def create
  end
end

Setting up application credentials

Since Rails 5.2 there has been a new handy way to create encrypted secret keys inside apps instead of the legacy secrets.yml file or environment variables localized to your machine/servers.

This essentially allows you to version your API keys and access codes securely assuming you have a master.key file.

On the first run, the command below will generate and decrypt a file called credentials.ymc.enc in the config folder. That command looks like this:

rails credentials:edit

Your mileage may vary here and it might spit back an error saying to choose which code editor to open the credentials file with. I tend to use VS code so passing EDITOR=code --wait rails crendentials:edit should do the trick.

When that file opens you'll see a YAML file appear which is just a space to add any API keys that are sensitive and shouldn't be shared. Stripe's publishable key should be the only one you expose to the front-end of your apps.

I'll be using test keys of course for this tutorial but you may eventually need live keys.

You can either swap those out inside your main credentials file or generate environment-specific application credentials. That might look like rails credentials:edit --environment=development for example where the live keys would live in the main credentials file (rails credentials:edit) and any test keys live in the development version. There's a whole can of worms with this feature of Rails. I recommend checking the documentation for more information. Perhaps I'll do a future guide just on credentials.

At this point, we don't have our pricing IDs added to our secret credentials like we previously did with the Stripe API keys. We can add those now along with our main API keys:

# aws:
#   access_key_id: 123
#   secret_access_key: 345

# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: XXXXXXXX

stripe:
  secret_key: sk_test_XXXXXXXXXXXXXX
  public_key: pk_test_XXXXXXXXXXXXXX
  pricing:
    monthly: price_XXXXXXXXXXXX
    annual: price_XXXXXXXXXXXXX

Here's how mine currently looks. I removed my keys for obvious reasons. Save the file and close it and you should see that it has been encrypted successfully.

If you want to give your keys a trial run you can verify they output via rails console:

rails console
> Rails.application.credentials.dig(:stripe, :pricing, :monthly)
#=> price_XXXXXXXXXXXX

To kick off a new Stripe Checkout session we can call the associated class via the Stripe gem we installed previously. The goal here is to pass the plan type from the front end to the controller, assign the appropriate price_id (the ones we created in Stripe previously) and launch a new Stripe checkout instance. A new overlay should appear with the product/pricing data and a credit card form. To get the Stripe Checkout overlay to appear we need to return a session_id to a Stripe hosted page directly.

More view code

To get Stripe data from the front-end to the backend we need to ensure we can first have access to our Stripe publishable API key. Let's add a meta tag to the main layout file to have it available globally. In the template I am using I leverage a partial called _head.html.erb that gets injected inside the application.html.erb file. Here's what I have at the moment.

<!-- app/views/shared/_head.html.erb-->
<head>
  <title>
    <%= content_for?(:title) ? yield(:title) : "Kickoff Tailwind" %>
  </title>

  <%= csrf_meta_tags %>
  <%= csp_meta_tag %>

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <%= stylesheet_link_tag  'application', 'data-turbolinks-track': 'reload' %>
  <%= javascript_include_tag "application", defer: true %>
  <%= javascript_importmap_tags %>
  <%= javascript_include_tag 'https://js.stripe.com/v3/', 'data-turbolinks-track': 'reload' %>
</head>

Passing the Stripe API key

While we could supply our Stripe private key in every instance we need it, it's much easier to make it globally accessible in the application controller. You could previously create a new initializer file in config/initializers but I kept running into issues with that. For the time being, I just added it to the ApplicationController file for quick reusability across the app.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  before_action :configure_permitted_parameters, if: :devise_controller?
  before_action :set_stripe_key # add this line

  protected

    def configure_permitted_parameters
      devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
      devise_parameter_sanitizer.permit(:account_update, keys: [:name])
    end

  # add the code below
  private
    def set_stripe_key
      Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
    end

The code you see here is part of our Devise installation included in my template. I added a new set_stripe_key private method that gets called before every action. This is a code smell to a degree since we'll be setting the API key in every request but for now, it should do.

Pricing page updates

Let's update the view quickly to check if the end-user is signed in or not and link the payment buttons accordingly.

<!-- app/views/static/pricing.html.erb -->
<!-- more code here--->

<% if user_signed_in? %>
  <%= form_tag purchase_checkouts_path,  method: :post, data: { turbo: false } do %>
    <input type="hidden" name="price_id" value="<%= Rails.application.credentials.dig(:stripe, :pricing, :monthly) %>" />
    <%= submit_tag "Subscribe for $9/mo", data: { disable_with: "Hold please..." }, class: "px-3 py-4 bg-teal-600 block no-underline w-full text-center rounded font-bold text-white shadow-sm" %>
  <% end %>

  <%= form_tag purchase_checkouts_path,  method: :post, data: { turbo: false } do %>
    <input type="hidden" name="price_id" value="<%= Rails.application.credentials.dig(:stripe, :pricing, :annual) %>" />
    <%= submit_tag "Subscribe for $90/yr", data: { disable_with: "Hold please..." }, class: "px-3 py-4 bg-indigo-600 block no-underline w-full text-center rounded font-bold text-white shadow-sm" %>
  <% end %>
<% else %>
  <%= link_to "Subscribe for $9/mo", new_user_session_path, class: "px-3 py-4 bg-teal-600 block no-underline w-full text-center rounded font-bold text-white shadow-sm" %>

  <%= link_to "Subscribe for $90/yr", new_user_session_path, class: "px-3 py-4 bg-indigo-600 no-underline block w-full text-center rounded font-bold text-white shadow-sm" %>
<% end %>
<!-- more code here--->

This is kind of a mess but gets the job done.

What's going on here?

If a user isn't signed in we can link to the sign-in page. We do this because we eventually want to associate a user with a subscription once we charge them. Doing so requires getting a user's data so them being authenticated within the app is crucial.

If they are signed in we can render the checkout buttons that act as the entry point to Stripe's Checkout solution.

Each button acts as a form that POSTs to the app passing an associated price_id we defined in our credential files in a previous step. You can pass additional parameters here but let's keep it simple for now.

I passed a price_id parameter as a hidden field the user doesn't see on the front end. This ultimately allows us to pass the right price relative to the plan to Stripe's API.

The low code way

Since Stripe has a built-in checkout I'm going to leverage it for the sake of not reinventing the wheel. As your app gets more complicated and you need more control, you can create an entirely custom checkout experience should you want to.

Inside the checkouts_controller I added the new Stripe Checkout Session instance. You'll see some local variables that hook into what pricing a user has chosen on the front end. We'll use a parameter passed in the request to denote what pricing to use.

# app/controllers/purchase/checkouts_controller.rb

class Purchase::CheckoutsController < ApplicationController
  before_action :authenticate_user!

  def create
    price = params[:price_id] # passed in via the hidden field in pricing.html.erb

    session = Stripe::Checkout::Session.create(
      customer: current_user.stripe_id,
      client_reference_id: current_user.id,
      success_url: root_url + "success?session_id={CHECKOUT_SESSION_ID}",
      cancel_url: pricing_url,
      payment_method_types: ['card'],
      mode: 'subscription',
      customer_email: current_user.email,
      line_items: [{
        quantity: 1,
        price: price,
      }]
    )
    #render json: { session_id: session.id } # if you want a json response
    redirect_to session.url, allow_other_host: true
  end
end

The Stripe docs do better justice than I would be explaining everything happening here but we are doing the following:

  1. Passing which plan a customer wants in the params/request. From that, we assign a local price_id variable based on the price_ids we got back when we created the two prices for our product in the Stripe account.
  2. Creating a local session variable and assigning it to the Checkout::Session object.
  3. The session object allows us to assign a customer's stripe id (if already present). If nothing is there then a new one will be created.
  4. The client_reference_id is a way to store data about customers from your application. Here we just use the user id which comes in handy later.
  5. Call back URLs. success (post-purchase) and cancel (where customers go if they back out). Notice the {CHECKOUT_SESSION_ID} parameter. This is resupplied by Stripe automatically once the charge is successful. Sadly, I needed to write the URL in a rather hacky way to get it to work. You can't use full rails URL helpers here.
  6. A way to establish payment method types. card is the most common but you could also accept Apple Pay, Google Pay, etc...
  7. The mode. This could be payment, subscription, setup. (read more here about this)
  8. line items - Here you can add a bunch of prices/products at once or do like we are and pass one during the session creation. This depends on your preferences and UI. I like to have separate actions for different plans personally.
  9. Much more data can be added to this session variable like taxes, shipping details, and additional payment types.
  10. The method called allow_other_host: true is a newer security feature in Rails 7. You can bypass a host and redirect to the outside of the main application if set to true.

Add a success page

After a successful checkout, we need somewhere for users to go. Add this to your routes to make this sync up on your end:

# config/routes.rb

namespace :purchase do
  resources :checkouts
end

get "success", to: "checkouts#success"

This change allows us to assign a success URL that the user is directed to following a successful purchase.
This addition points to the app/controllers/purchase/checkouts_controller.rb file and a new #success action in it. Finally, be sure to create a new view inside app/views/purchase/checkouts called success.html.erb. We'll add code to it in a bit.

Testing things out

This is a lot to cover! Congrats on making it this far if you have.

Hopefully, you see something similar to this when clicking a subscribe button at this point. Each button should fire up a Stripe Checkout modal that displays different plan information.

Stripe Checkout Session Launched successfully

Upon checkout out using test card numbers and data, you should navigate successfully to a success page.

Bonus points

Notice the email field on the Stripe Checkout UI? Wouldn't be cool if we can prefill those with the logged-in user's email address? Well, we totally can 🦾.

Modify the session object like below:

# app/controllers/purchase/checkouts_controller.rb

class Purchase::CheckoutsController < ApplicationController
  before_action :authenticate_user!

  def create
    session = Stripe::Checkout::Session.create(
      customer: current_user.stripe_id,
      customer_email: current_user.email, # I added this line
      client_reference_id: current_user.id,
      success_url: root_url + "success?session_id={CHECKOUT_SESSION_ID}",
      cancel_url: pricing_url,
      payment_method_types: ['card'],
      mode: 'subscription',
      line_items: [{
        quantity: 1,
        price: price,
      }]
    )
  end

  def success
  end
end

This updates the UI to match something like the following:

Stripe Checkout Email prefilled

Success view

While it may look like we are done, there's a bit more work to do following the purchase flow. Upon a successful charge, we still need to send our new customers somewhere. We can do that by appending some simple logic to the success action.

class Purchase::CheckoutsController < ApplicationController
  #...
  def success
    session = Stripe::Checkout::Session.retrieve(params[:session_id])
    @customer = Stripe::Customer.retrieve(session.customer)
  end
 #...
end

After a successful charge, we redirect the user to the success page where we retrieve their name via the Stripe API. The session_id is what we use to query to API and obtain customer data.

Be sure to create a new view in app/views/purchase/checkouts called success.html.erb to properly render the view.

I made some really simple boilerplate code there you can tweak as you wish:

<!-- app/views/purchase/checkouts/success.html.erb-->

<div class="max-w-xl mx-auto p-8 rounded-xl shadow-xl mt-6">
<div class="h-[200px] bg-gray-100 overflow-hidden mb-6 rounded-lg">
  <img
    src="https://source.unsplash.com/random/800x400"
    class="bg-gray-100 object-cover rounded-lg"
  />
  </div>
  <div>
    <h1 class="font-black text-3xl mb-3 text-center">Thanks for subscribing, <%= @customer.name %>!</h1>
    <%= link_to "Continue", root_url, class: "px-3 py-4 bg-black no-underline block w-full text-center rounded font-bold text-white shadow-sm" %>
  </div>
</div>

Try purchasing a plan in test mode and see if you make it through 🀞🀞.

It worked for me! Here's a screenshot of the test transaction

test transaction stripe

Listening to webhooks and subscribing a user

While getting Stripe's checkout to work and make a successful transaction is a huge win, we don't yet tie that state of that subscription to our actual application. If you recall earlier we made a Subscription model. This will be that glue that connects a user to a subscription and Stripe's API thereafter.

Today, the most recommended way to ensure a successful transaction occurred via Stripe is to listen to events via webhooks. If you're new to webhooks don't worry. At the surface, it's just a way for a service to cast events as they happen. Using those events your application can receive data about them through a pre-defined endpoint of your choice. Based on what occurs you can perform nearly any kind of operation in an automated way.

Testing webhooks with Stripe CLI

Testing webhook events locally is a lot easier with Stripe's new CLI tool. If you haven't already, be sure to install it.

brew install stripe/stripe-cli/stripe

With that installed you can set up your local environment to simulate webhook events.

Start by logging in via Stripe CLI

stripe login

This should redirect you to log in if you haven't already and then verify a unique string that output in your console. Try visiting this page for a step-by-step guide if you get stuck.

When you log in successfully you are given a webhook secret key that lasts a maximum of 90 days. I tend to throw this in my application crendentials file and access it that way for security's sake. It's easy enough to revoke the secret key from your Stripe account so I'll leave it up to you how "secure" you want to be.

bin/rails credentials:edit

Next to our other Stripe API keys, I added one more for the webhook_signing_secret key:

stripe:
  secret_key: sk_test_XXXXXXXXXXXXXXXXXXXXX
  public_key: pk_test_XXXXXXXXXXXXXXXXXXXXX
  webhook_signing_secret: whsec_XXXxXXXXXXXXXXXXXXXXX
  pricing:
    monthly: price_XXXXXXXXXXXXXXXXXXXXX
    annual: price_XXXXXXXXXXXXXXXXXXXXX

Generate a webhook events controller

We need a new endpoint to accept events from Stripe's webhooks. Let's create a unique controller to handle this logic.

rails g controller webhooks --skip-helper --skip-assets
      create  app/controllers/webhooks_controller.rb
      invoke  erb
      create    app/views/webhooks
      invoke  test_unit
      create    test/controllers/webhooks_controller_test.rb

A couple of notes about the above command:

  • --skip-helper skips any helpers that get added automatically to app/helpers when you run a default controller generator command.
  • --skip-assets skips adding any CSS files.

Configure routes

With the new controller in place, we need a custom route to set up the endpoint for our API.

# config/routes.rb

Rails.application.routes.draw do
#...
  resources :webhooks, only: :create
#...
end

In the world of rails, the create action is usually considered a POST request type in the RESTful model.

Controller logic

Inside the WebhooksController I added a new create action. Inside it, I'll add some code to handle the input requests.

An important step to not ignore is to bypass the CSRF functionality that comes baked into the framework. In this case, we don't want to enforce cross-site request forgery. We'll check the authenticity of the request in a different way.

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    # Do stuff
  end
end

Test it out

Let's fire up the local forwarding of simulated Stripe events to our new route for webhooks

stripe listen --forward-to localhost:3000/webhooks

We can test things out by firing a test event via the console. In a new tab type the following and hit return.

stripe trigger checkout.session.completed

If you look back in the other tab running the stripe listen session you should see a paper trail of activity which hopefully verifies it's working even though we aren't parsing any data just yet.

Handling events

With everything in place we need to think about how we're going to process the data that comes in. To help plan I created a quick outline of concerns to attend to.

  1. What kind of event is it?
  2. What should we do with this specific event?
  3. Verify signatures and parse events
  4. Kick-off background jobs/other logic to update subscription/user data in-app.

To parse the events we need to handle them as JSON which is pretty typical these days. In the controller, we need some additional logic to parse JSON with ruby and then catch specific events we're interested in responding to.

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    # docs: https://stripe.com/docs/payments/checkout/fulfill-orders
    # receive POST from Stripe
    payload = request.body.read
    signature_header = request.env['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = Rails.application.credentials.dig(:stripe, :webhook_signing_secret)
    event = nil

    begin
      event = Stripe::Webhook.construct_event(
        payload, signature_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      # Invalid payload
      render json: {message: e}, status: 400
      return
    rescue Stripe::SignatureVerificationError => e
      # Invalid signature
      render json: {message: e}, status: 400
      return
    end

    # Handle the event
    case event.type
    when 'checkout.session.completed'
      # Payment is successful and the subscription is created.
      # Provision the subscription and save the customer ID to your database.
    when 'checkout.session.async_payment_succeeded'
      # Some payments take longer to succeed (usually noncredit card payments)
      # You could do logic here to account for that.
    when 'invoice.payment_succeeded'
      # Continue to provide the subscription as payments continue to be made.
      # Store the status in your database and check when a user accesses your service.
      # This approach helps you avoid hitting rate limits.
    when 'invoice.payment_failed'
      # The payment failed or the customer does not have a valid payment method.
      # The subscription becomes past_due. Notify the customer and send them to the
      # customer portal to update their payment information.
    else
      puts "Unhandled event type: #{event.type}"
    end
  end
end

There's a lot here but the gist is the following:

  1. When we receive a POST request to our app from Stripe we can absorb the data and parse it.
  2. When parsing it we can check its authenticity and verify it with the signing secret key we pass to the Stripe::Webhooks.construct_event call.
  3. Finally, we use the event and compare what type of event it is we are receiving to do more operations automatically in-app.

Keeping views from breaking

If you managed to subscribe a customer congrats! At this point, our view logic isn't compensating for subscribers so we can adjust our pricing page to compensate. This adds additional logic to the view but we can extract some of it.

There are three states we need to account for:

  1. Logged out users
  2. Logged in unsubscribed users
  3. Logged in subscribed users.

We already account for number 1 above but now we'll need to figure out 2 and 3.

First, let's add a method to the user model to check for any active subscriptions. Since our app allows a user to have many subscriptions we need to treat subscriptions as a multiple when finding data on them.

### app/models/user.rb
class User < ApplicationRecord
  # more code is here but ommitted for brevity

  def subscribed?
    subscriptions.where(status: 'active').any?
  end
end

Since we previously defined subscriptions in the app we can query for any that might be active for a given user. If so, it's safer to assume that the user has an active subscription and is indeed subscribed.

This can trickle down to the view layer like so:

<!-- app/views/static/pricing.html.erb -->
<div class="max-w-xl mx-auto p-8 rounded-xl shadow-xl mt-6">
  <div class="h-[200px] bg-gray-100 overflow-hidden mb-6 rounded-lg">
    <img
      src="https://source.unsplash.com/random/800x400"
      class="bg-gray-100 object-cover rounded-lg"
    />
  </div>
  <h1 class="font-black text-3xl mb-2">Subscribe for access</h1>
  <p class="text-lg text-gray-700 mb-3">
    You'll get never before seen content, an extra article per week, and access
    to our private community.
  </p>
  <div class="grid grid-cols-2 gap-6">
    <% if user_signed_in? && !current_user.subscribed? %> <%= form_tag
    purchase_checkouts_path, method: :post, data: { turbo: false } do %>
    <input
      type="hidden"
      name="price_id"
      value="<%= Rails.application.credentials.dig(:stripe, :pricing, :monthly) %>"
    />
    <%= submit_tag "Subscribe for $9/mo", data: { disable_with: "Hold please..."
    }, class: "px-3 py-4 bg-teal-600 block no-underline w-full text-center
    rounded font-bold text-white shadow-sm" %> <% end %> <%= form_tag
    purchase_checkouts_path, method: :post, data: { turbo: false } do %>
    <input
      type="hidden"
      name="price_id"
      value="<%= Rails.application.credentials.dig(:stripe, :pricing, :annual) %>"
    />
    <%= submit_tag "Subscribe for $90/yr", data: { disable_with: "Hold
    please..." }, class: "px-3 py-4 bg-indigo-600 block no-underline w-full
    text-center rounded font-bold text-white shadow-sm" %> <% end %> <% elsif
    user_signed_in? && current_user.subscribed? %>
    <div
      class="bg-yellow-100 p-6 rounded-lg text-center text-yellow-900 col-span-2"
    >
      <p>
        You're already subscribed. <%= button_to "Manage your subscription",
        billing_path, method: :post, data: { turbo: false }, class: "underline"
        %>.
      </p>
    </div>
    <% else %> <%= link_to "Subscribe for $9/mo", new_user_session_path, class:
    "px-3 py-4 bg-teal-600 block no-underline w-full text-center rounded
    font-bold text-white shadow-sm" %> <%= link_to "Subscribe for $90/yr",
    new_user_session_path, class: "px-3 py-4 bg-indigo-600 no-underline block
    w-full text-center rounded font-bold text-white shadow-sm" %> <% end %>
  </div>
</div>

Notice now we have an if-else if and else scenario in the view. It feels gross but does the job. The new method on the user model can be chained so current_user.subscribed? effectively extracts so more advanced logic behind the scenes.

Handling necessary events

While there is a multitude of events you could respond to with webhooks I want to keep this pretty simple based on the code in our controller thus far. There's also a ton of extraction you could do here but for brevity's sake, I'll keep it kind of scrappy.

The main event we'll look for to confirm payment is the checkout.session.completed event. This essentially means a customer has gone completely through the payment on Stripe's checkout UI. There is an additional even that can listen for async payments (ones that happen later) but I'll save that for another time perhaps.

checkout.session.completed

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController

  def create
  #.... tons of other code

  # Handle the event
    case event.type
    when 'checkout.session.completed'
      # If a user doesn't exist we definitely don't want to subscribe them
      return if !User.exists?(event.data.object.client_reference_id)
      # Payment is successful and the subscription is created.
      # Provision the subscription and save the customer ID to your database.
      fullfill_order(event.data.object)
    else
    # more events + logic
    end
  end

  private

  def fullfill_order(checkout_session)
    # Find user and assign customer id from Stripe
    user = User.find(checkout_session.client_reference_id)
    user.update(stripe_id: checkout_session.customer)

    # Retrieve new subscription via Stripe API using susbscription id
    stripe_subscription = Stripe::Subscription.retrieve(checkout_session.subscription)

    # Create new subscription with Stripe subscription details and user data
    Subscription.create(
      customer_id: stripe_subscription.customer,
      current_period_start: Time.at(stripe_subscription.current_period_start).to_datetime,
      current_period_end: Time.at(stripe_subscription.current_period_end).to_datetime,
      plan: stripe_subscription.plan.id,
      interval: stripe_subscription.plan.interval,
      status: stripe_subscription.status,
      subscription_id: stripe_subscription.id,
      user: user,
    )
  end
end

To fulfill an order I made a new private method called fullfill_order (this should probably be extracted to a background job or plain old ruby object elsewhere). We pass the checkout session object as the main argument. From there I do some preemptive logic to first make sure a user exists in the database. If they don't we simply return and don't subscribe them. If they do exist we'll find them by id and also update the stripe_id column for the user to include Stripe's customer id attribute. This is handy for calling the API later for viewing any subscription, invoice, or charge data related to a given user.

Once we have a user to work with I retrieve the newly added subscription from the Stripe API using the checkout session subscription value. That is stored in a local variable I called stripe_subscription which we then use to create a local subscription object in our database that maps to the same user. We add a few handy attributes including plan_id, customer_id, interval, start and end dates, and status.

Assuming the webhook event fires successfully and our app responds to the POST request at /webhooks from Stripe, customers should be able to successfully subscribe now!

Why store our subscription data?

This is a good practice to limit rate requests on the Stripe API. While not ideal to host this data allows us to assume a given user is subscribed or not based on events firing back from Stripe itself. It also is a great way to document the state of a subscription so we can properly notify customers as needed.

I'll be using the Stripe customer portal to have users manage their subscriptions via Stripe instead of in our app. This makes things way easier and more efficient.

invoice.payment_failed

If an invoice payment fails we can direct the user to the customer portal to manage their billing details. The failure is usually a result of an expired card or something similar.

invoice.payment_succeeded

If an invoice.payment_succeeded event is fired we can query for the subscription and update the new periods, status, and interval. This ultimately maintains the life of the subscription in-app and syncs with Stripe. It also gives a chance to notify customers of a successful payment.

Stripe offers a complete invoicing solution that automates this for you. I'll make use of it in this guide but you could just as easily roll your own invoicing if you want more control. Keep in mind doing so requires turning off invoicing in Stripe as well as adding the logic for receipts(emails, data, additional webhook events, etc...). And just like subscription logic you can listen to invoice events and perform actions after being triggered via webhooks.

To update the code to handle this logic I modified the webhooks controller once more.

# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    #... lots of code removed for brevity

    # Handle the event
    case event.type
      when 'checkout.session.completed'
      # do stuff
      when 'invoice.payment_succeeded'
        # return if a subscription id isn't present on the invoice
        return unless event.data.object.subscription.present?
        # Continue to provision the subscription as payments continue to be made.
        # Store the status in your database and check when a user accesses your service.
        stripe_subscription = Stripe::Subscription.retrieve(event.data.object.subscription)
        subscription = Subscription.find_by(subscription_id: stripe_subscription)

        subscription.update(
          current_period_start: Time.at(stripe_subscription.current_period_start).to_datetime,
          current_period_end: Time.at(stripe_subscription.current_period_end).to_datetime,
          plan: stripe_subscription.plan.id,
          interval: stripe_subscription.plan.interval,
          status: stripe_subscription.status,
        )
        # Stripe can send an email here for invoice paid attempts. Configure in your account OR roll your own below
      when 'invoice.payment_failed'
       # do stuff
      else
        puts "Unhandled event type: #{event.type}"
      end
    end
  end
end

invoice.payment_failed

Finally, handling an invoice.payment_failed event allows you to cast an update (usually an email) to the customer to take action to update their billing information. This section is where I'll get into the new Stripe Customer Portal solution which is a one-stop place for customers to manage their billing details directly with Stripe. This saves a lot of logic needing to be created from scratch on our end but with that decision, you sacrifice some control. All in all, unless you're a highly established business or just want that much control Stripe Customer Portal should be plenty to get billing done in a pinch.

I'll revisit this section in a bit.

Stripe customer portal

Before we can write the event logic for invoice.payment_failed I want to get the customer portal set up. This will be at an endpoint we can redirect customers to when necessary.

Defining routes

We need a new endpoint on our app to send customers to. This will be a place where we create another session and fire up the customer portal.

# config/routes.rb
resources :billings, only: :create

Create a controller

With the new route added we'll need to add a billings controller. Inside the file, we can set up the portal instance. Rails prefers to make controller namespaces plural if you're wondering.

# app/controllers/billings_controller.rb

class BillingsController < ApplicationController
  before_action :authenticate_user!

  def create
    session = Stripe::BillingPortal::Session.create({
      customer: current_user.stripe_id,
      return_url: root_url,
    })

    redirect_to session.url, allow_other_host: true #hits stripe directly
  end
end

This small bit of code accounts launching the Stripe customer portal. We grab the previously saved customer id from Stripe and pass it to customer. Then set the return URL to our root URL for now. Be sure to use URL helpers here and not path helpers.

Let's test this out on our pricing page. If you haven't already subscribed you'll need to do that to see the UI that links to manage the currently subscribed user's subscription. I'll update the link to manage that subscription to the new billing path.

<% if user_signed_in? && !current_user.subscribed? %> <%# checkout buttons go
here %> <% elsif user_signed_in? && current_user.subscribed? %>
<div
  class="bg-yellow-100 p-6 rounded-lg text-center text-yellow-900 col-span-2"
>
  <p>
    You're already subscribed. <%= button_to "Manage your subscription",
    billings_path, data: { turbo: false }, class: "underline" %>.
  </p>
</div>
<% else %> <%# login buttons go here %> <% end %>

The mainline to focus on is this bit of erb/html

<p>
  You're already subscribed. <%= button_to "Manage your subscription",
  billings_path, { turbo: false }, class: "underline" %>.
</p>
`

I swapped what was a link_to helper for a button_to helper which is essentially a form underneath the surface. Based on our routing we'll be creating a POST request when this gets clicked.

If upon clicking you receive an error that reads:

You can’t create a portal session in test mode until you save your customer portal settings in test mode at https://dashboard.stripe.com/test/settings/billing/portal.

Visit that link to enable settings to use in test mode. Be sure to configure whatever settings are required and also the optional ones. Here's a screenshot of the demo app I'm using.

demo stripe customer portal

With those settings added, you can try to create a new customer portal billing session again.

Below I show it working!

stripe customer portal works!

Revisiting invoice.payment_failed

With the portal working, we now have a place to send active subscribers to manage their plans. I made it possible in Stripe to also update their plan choice. Doing this means we'll need to account for some additional webhook events.

For the invoice.payment_failed event type I want to send an email to the user about the occurrence. In that email, we can add a link to their customer portal.

Add a mailer

rails g mailer subscription payment_failed
      create  app/mailers/subscription_mailer.rb
      invoke  erb
      create    app/views/subscription_mailer
      create    app/views/subscription_mailer/payment_failed.text.erb
      create    app/views/subscription_mailer/payment_failed.html.erb
      invoke  test_unit
      create    test/mailers/subscription_mailer_test.rb
      create    test/mailers/previews/subscription_mailer_preview.rb

The command above creates a SubscriptionMailer with payment_failed methods baked in. We'll need to amend things a bit but this gives us a good starting point.

Within the invoice.payment_failed event we can listen for a few values we'll need to send the email.

We can test the event using Stripe's CLI tool by running:

stripe trigger invoice.payment_failed

I tend to throw a debugger statement in the case statement to help me figure out what to query from Stripe's API.

We'll want to find the customer ID using event.data.object.customer. In my case, it returns a string that starts with cus_xxxxx... We can use that customer id to look up the user we'll need to send the email to in our app.

class WebhooksController < ApplicationController
  def create
  # code omitted for brevity

    case event.type
    # more code....
    when 'invoice.payment_failed'
      # The payment failed or the customer does not have a valid payment method.
      # The subscription becomes past_due so we need to notify the customer and send them to the customer portal to update their payment information.

      # Find the user by stripe id or customer id from Stripe event response
      user = User.find_by(stripe_id: event.data.object.customer)
      # Send an email to that customer detailing the problem with instructions on how to solve it.
      if user.exists?
        SubscriptionMailer.with(user: user).payment_failed.deliver_now
      end
    else
      puts "Unhandled event type: #{event.type}"
    end
  end
end

You can trigger this logic by running stripe trigger invoice.payment_failed once more.

In my rails server logs I'm able to see the email was indeed sent:

SubscriptionMailer#payment_failed: processed outbound mail in 9.8ms
Delivered mail 61f42cd417751_1009d4628-3d9@XXXXXXXXXXXX (9.4ms)
Date: Fri, 28 Jan 2022 11:50:12 -0600
From: [email protected]
To: [email protected]
Message-ID: <61f42cd417751_1009d4628-3d9@XXXXXXXXXXX>
Subject: Payment failed
Mime-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--==_mimepart_61f42cd416df3_1009d4628-4a4";
 charset=UTF-8
Content-Transfer-Encoding: 7bit


----==_mimepart_61f42cd416df3_1009d4628-4a4
Content-Type: text/plain;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

Subscription#payment_failed

Hi, find me in app/views/subscription_mailer/payment_failed.text.erb


----==_mimepart_61f42cd416df3_1009d4628-4a4
Content-Type: text/html;
 charset=UTF-8
Content-Transfer-Encoding: 7bit

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <h1>Subscription#payment_failed</h1>

<p>
  Hi, find me in app/views/subscription_mailer/payment_failed.html.erb
</p>

  </body>
</html>

----==_mimepart_61f42cd416df3_1009d4628-4a4--

The email template needs some TLC still but we can confirm it works πŸ‘.

Update the mailer

Knowing our email sends we can set up the logic to send to the correct customer.

# app/mailers/subscription_mailer.rb
class SubscriptionMailer < ApplicationMailer
  def payment_failed
    @user = params[:user]

    mail to: @user.email, subject: "Payment attempt failed"
  end
end

I modified the method to use the following code. We are passing an instance of the user to the mailer class for use within the view. We'll need this to address the end-user more personally. I updated the subject line of the email and also sent it directly to the user's email address.

Previewing the template

Rails comes with a handy preview tool found in test/mailers/previews. Look for the file subscription_mailer_preview.rb. Inside it we'll need to change it to the following:

# test/mailers/previews/subscription_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/subscription_mailer
class SubscriptionMailerPreview < ActionMailer::Preview
  def payment_failed
    SubscriptionMailer.with(user: User.first).payment_failed
  end
end

This is for the sake of viewing the email in the browser at http://localhost:3000/rails/mailers. There you will find all the mailer templates that have been generated if created using a rails generator.

Right now the template looks awful so let's at least add some updated copy and a link to the user's account settings where they can update their billing information.

Update the email template

When you generate a mailer with a method on the command line there are two templates created. One HTML and one text. I'm going to delete the text-only one for now as it's not necessary.

<!-- app/views/subscription_mailer -->
<h1>Payment attempt failed</h1>

<p>Hi <%= @user.first_name %>,</p>

<p>It looks like your latest automated payment attempt failed</p>

<p>
  To not see a lapse in your subscription, we suggest updating your billing
  information as soon as possible
</p>

<p>
  <%= link_to "Update billing information", edit_user_registration_url, class:
  "underline", target: :_blank %>
</p>

Notice I linked to edit_user_registration_url here instead of directly to the billing portal. We can't reliably embed a link/button/form in an email to submit a post request. This is kind of by design for security purposes. Instead, we'll redirect the user to their actual account where we can add a button to do the billing portion. Let's add that now.

Add billing button to account settings

The template I'm using comes preconfigured with Devise which handles all our user authorization. We can hook into the preexisting views to add a call to action to manage billing information. I updated the template quickly to reflect. The code below needs to be outside of the layout rendering in the view. This is because the button elements would cause the form to submit if they were within. We don't want that.

<!-- app/views/devise/registrations/edit.html.erb-->
<!-- code above omitted for brevity -->
<%= render layout: "auth_layout", locals: { title: "Edit account" } do %> <%=
form_for(resource, as: resource_name, url: registration_path(resource_name),
html: { method: :put }, data: { turbo: false }) do |f| %>
<!-- more code here -->
<div class="mb-6">
  <%= f.submit "Update", class: button_class(theme: "primary", variant:
  "expanded") %>
</div>
<% end %>
<hr class="mb-6" />

<div class="flex justify-between items-center mb-6">
  <div>
    <%= button_to("Manage billing", billings_path, data: {turbo: false}, class:
    button_class(theme: "dark")) %>
  </div>
  <div>
    <%= button_to("Cancel my account", registration_path(resource_name), data: {
    confirm: "Are you sure?", turbo: false }, method: :delete, class:
    "underline") %>
  </div>
</div>
<% end %>

The key action I added was the "Manage Billing" button. We'll want to additionally make sure a user is subscribed before displaying this so you can extend things one step further.

<!-- app/views/devise/registrations/edit.html.erb -->
<div class="flex justify-between items-center mb-6">
  <% if current_user.subscribed? %>
  <div>
    <%= button_to("Manage billing", billings_path, data: {turbo: false}, class:
    button_class(theme: "dark")) %>
  </div>
  <% end %>
  <div>
    <%= button_to("Cancel my account", registration_path(resource_name), data: {
    confirm: "Are you sure?", turbo: false }, method: :delete, class:
    "underline") %>
  </div>
</div>

Clicking the Manage Billing button now allows a user to do just that with Stripe Customer Portal.

Listening to additional events

We'll need to account for a few more events to make this come full circle. Those include:

Your implementation might require different events but below are the ones I'd suggest.

  1. Subscription cancellation - customer.subscription.updated and cancel_at_period_end (needs to be set to false to cancel). Read this for more context.

  2. Subscription updates - customer.subscription.updated and check for subscription price differences - change plan

Subscription updates are mostly tailored to unique plan types that unlock a new tier in your product or service. You need to find out what price a customer is currently subscribed to and update anything locally with the new price. This might look like going from a basic plan to a pro plan and vice versa.

For the sake of this guide, having subscribed monthly or yearly doesn't unlock anything different other than a discount over time if you opted for yearly. With this in mind, we don't need to listen for subscription changes for now as the monthly and yearly are the same offering.

Cancelling subscriptions

We don't want to have a user subscribed in-app but unsubscribed on Stripe so to accommodate this we need to listen for the customer.subscription.update event. In the customer portal settings, I chose to allow a customer to cancel a plan but still have access through the current billing period. You can choose to end a subscription abruptly if you wish. That would be a different event called customer.subscription.deleted.

If the customer doesn't resubscribe in the interim we can remove subscription access when the period expires.

This code is gnarly and should be extracted but I wanted to just be clear about what's happening.

case event.type
# more events here
when 'customer.subscription.updated'
  # deletion
  stripe_subscription = event.data.object

  # if cancel_at_period_end is true the subscription will cancel
  if stripe_subscription.cancel_at_period_end == true
    subscription = Subscription.find_by(subscription_id: stripe_subscription.id)

    if subscription.present?
    # update the local subscription data with the status and other details that may have changed.
      subscription.update(
        current_period_start: Time.at(stripe_subscription.current_period_start).to_datetime,
        current_period_end: Time.at(stripe_subscription.current_period_end).to_datetime,
        interval: stripe_subscription.plan.interval,
        plan: stripe_subscription.plan.id,
        status: stripe_subscription.status
      )
    end
  end
else
  puts "Unhandled event type: #{event.type}"
end

If you recall we're checking a lot of places for a status of active on any subscriptions associated with a user. If a subscription lapses that status will change and thus unsubscribe the user on our end. You could also track this type of logic with some simple boolean properties on the user or subscription model. Given that our app supports multiple subscriptions (not that we offer them) I made it a bit more rugged.

Going live

While we've been testing these endpoints locally the real secret sauce is going live. Your /webhooks endpoint needs to be an absolute URL that gets added to Stripe in your account. Look for the Webhooks tab under "Developer". There's a section called "Hosted endpoints". Be sure to add your endpoint there when going live. That might look like https://demoapp.com/webhooks for example.

Stripe hosted endpoints screenshot

Go on and build!

So we've come extremely far. Stripe Checkout and Stripe Customer Portal are huge leaps to much richer billing experiences for customers and the developers behind the scenes building it. With a few lines of code, you can have a full functioning checkout and customer admin tool that is entirely automated. What a time to be alive!

Tags: stripe
Link this article
Est. reading time: 48 minutes
Stats: 6,203 views

Categories

Collection

Part of the Ruby on Rails collection