How to Sell a One-time Purchase in Rails With Stripe

Have you ever wanted to accept payments in Rails? With Stripe Checkout, it's never been easier. Jeffrey Morhous shows us how.

Used by millions of companies, Stripe provides payment infrastructure for applications to handle subscriptions and one-time purchases. Stripe Checkout allows us to easily accept card payments via a hosted payments page, which is crafted to increase conversions. Combining this with webhooks allows developers to sell products and subscriptions and then deliver them digitally.

While it's technically possible to handle payments yourself using an existing provider, Stripe provides a number of benefits. For starters, it's faster. Stripe has over 4,000 people working on making payments as simple, secure, and convenient as possible.

Furthermore, having your payments 'powered by Stripe' is an easy way to build customer trust and increase conversion. Customers are typically hesitant to give their card or bank information to every website they visit, and rightfully so.

Stripe isn't the only provider in the online payment space. Paddle is another huge player in the online payment space. Paddle offers a lot of features similar to Stripe, but for a slightly higher fee and an anecdotally worse developer experience. While several other competitors are alternatives to Stripe, the sheer popularity of their products for online payments make them an easy choice.

It's common for SaaS companies to handle recurring subscriptions, but many products would benefit from one-time sales of digital products. This is equally as easy with Stripe's infrastructure, but their documentation seems to shy away from the topic.

Setup

To explain the process, we'll build an application that allows users to pay to post an advertisement on a craigslist-style board. For our specific example, our application will be a website for people looking for roommates. Users can browse the board for free but must pay a fee to post their own advertisement.

Create a Stripe Account

To begin, you'll need to head on over to Stripe's website to create an account. Sign up with your information or the information of your business if applicable.

A screenshot of the Stripe sign-up page A screenshot of the Stripe sign-up page

This article won't walk you through the exact details of creating an account, but their documentation will likely answer any questions you have. We're particularly interested in their payments and payouts product or, more specifically, Stripe Checkout.

Create a Basic Rails App

Once your Stripe account is set up, it's time to create a basic Rails app that we'll use for the integration. If you are integrating an existing app or are just interested in the Stripe part of the tutorial, skip ahead to "Basic Stripe Integration".

I'll use the following versions for this example:

  • Rails 6.1
  • Ruby 3.0.0

Assuming you have both Ruby and Rails already installed, go ahead and run the following:

rails new roommate-board

The name I've chosen for the app is roommate-board, but you're free to choose another. Just swap out the name anywhere else you see it mentioned in the example code.

Change into the directory that was just created with the following:

cd roommate-board

Then, run the app with the following:

rails s

If you see the typical Rails welcome page when you visit localhost:3000, then congrats, we're ready to get coding!

First, we'll add Devise to handle user authentication for us. Simply add gem 'devise' to your Gemfile and then run the following:

bundle install

Next, use the Devise generator to set things up by running the following:

rails generate devise:install

Next, connect Devise to a User model by running the following:

rails generate devise User

And finally, run the migration with the following:

rails db:migrate

Create a Roommate Board

If you're reading this, you're probably more interested in Stripe integration than front-end design, so we won't worry about styling our example.

Our next step is to create the model for posting! We'll use Rails' handy generators to create the model, migration, controller, and routes. Just run the following:

rails generate scaffold Post address:string rent:integer content:text

This model doesn't contain everything we'll need to integrate Stripe. We'll add it later in case you're referencing this tutorial for a project that you don't have the luxury of starting from scratch. Run the migration to create the database table with the following:

rails db:migrate

Next, we'll make the index view for posts the root of our application for convenience. In config/routes.rb, add the following line:

root 'posts#index'

It's pretty empty right now, and there's no clear way to sign in, sign out, or sign up. The functionality is already there, so let's just add some links in a header to make it clear to the user. In app/views/layouts/application.html.erb, we will add the necessary links. My file looks like this after making the changes:

<!DOCTYPE html>
<html>
  <head>
    <title>RoommateBoard</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <body>
    <header>
      <nav>
        <a href="/#" class="block">
          <h1>Roommate Board</h1>
        </a>
        <% if user_signed_in? %>
          <%= link_to "Manage Posts", manage_posts_url %>
          <%= link_to "Sign Out", destroy_user_session_url, :method => 'delete' %>
        <% else %>
          <%= link_to "New Post", new_user_registration_url %>
        <% end %>
      </nav>
    </header>
    <%= yield %>
  </body>
</html>

This gives a link to the homepage, and when users click "New Post", they'll be taken to the sign-up page. If they're signed in, it changes to a "Manage Posts" button and a "Sign Out" button. Without any styling and without being signed in, the root page now looks like this:

A screenshot of the basic posts index page A screenshot of the basic posts index page

Next, we want to scope each posting to a unique user. Essentially, we'll be creating a belongs_to association between a Posting and User, along with some relevant logic. We'll start with Rails’ handy migration generator; just run the following:

rails g migration AddUserIdToPosts

Now edit the migration file in db/migrate/ to look like this:

class AddUserIdToPosts < ActiveRecord::Migration[6.1]
  def change
    add_column :posts, :user_id, :integer
  end
end

Finally, run the migration with the following:

rails db migrate

Next, we'll add associations to the models themselves. In app/models/post.rb, add the following line:

belongs_to :user

And in app/models/user.rb, add the following:

has_many :posts

This bidirectional association gives us some handy Rails methods, like the ones we're about to use in the create and new methods. Navigate to app/controllers/posts_controller.rb (the posts controller) and take a look at the new method. Instead of @post = Post.new, use @post = current_user.posts.build.

In the create method, we'll do something similar. Replace @post = Post.new(post_params) with @post = current_user.posts.build(post_params). Together, the new and create methods should look like this:

# GET /posts/new
def new
  @post = current_user.posts.build
end

  # POST /posts or /posts.json
def create
  @post = current_user.posts.build(post_params)

  respond_to do |format|
    if @post.save
      format.html { redirect_to @post, notice: "Post was successfully created." }
      format.json { render :show, status: :created, location: @post }
    else
      format.html { render :new, status: :unprocessable_entity }
      format.json { render json: @post.errors, status: :unprocessable_entity }
    end
  end
end

This ensures that the user's id attribute is stored in every post, but it doesn't stop another user from editing or deleting a post that isn't theirs! In the same PostsController, let's write a method that gives us a Boolean indicator of whether a user owns a posting. Add this to the bottom of the controller:

def user_owns_post?
  @post.user == current_user
end

In the edit method, add the following (it will be the only code in there):

unless user_owns_post?
  # Redirect them to an error page
  redirect_to posts_path, flash: { error: "You are not the owner of that post!" }
end

In the destroy method, add this to the beginning:

unless user_owns_post?
  # Redirect them to an error page
  redirect_to posts_path, flash: { error: "You are not the owner of that post!" }
end

Next, we'll need some functionality for a user to manage their existing posts. The end goal is for a user to be able to create a post and have it in a draft state before they pay. Once they pay, it is set to active for them! In the PostsController, add a new method:

def manage
  @posts = current_user.posts
end

Next, add a route so that the user can get there! In config/routes.rb, add the following:

get "/manage_posts/" =>'posts#manage'

Finally, we'll create the view for the post management! Add a new file, app/views/posts/manage.html.erb. Then, copy the entire contents of app/views/posts/index.html.erb into it. Simply change the header from "Posts" to "Manage Posts". This is essentially an index page, but with an individual user's posts being the only ones indexed thanks to our controller logic. Technically, we could do this on the individual index page, but this separation makes it easier to add other functionalities later. For example, we'll now add a link to create a new post on this management page.

Under the header, simply add the following:

<%= link_to "New Post", new_post_url%>

This is all the setup we needed! It's a good idea to go through it to make sure everything is working properly. You should now be able to do the following:

  • Create a user account
  • Sign in
  • Sign out
  • View an index of existing postings
  • Create a new posting

If it's all working the way you expect, then it's time to move on to Stripe integration!

Stripe Integration

Adding the Stripe Ruby Gem

To begin, we'll just add the Stripe Ruby gem. In your Gemfile, add the following line:

gem 'stripe'

Follow that with running:

bundle install

For the next part, you'll need your Stripe API key. On the Stripe console, click "Developer" on the sidebar. An option for "API Keys" will reveal itself, so go ahead and click that next. Take a note of your "Secret Key," as we'll need it shortly.

But first, create an initializer in config/initializers. Create one called stripe.rb. In this file, add a single line that looks like this (but with your secret key substituted):

Stripe.api_key = <insert your key here as a string>

Storing Stripe Credentials

This method of storing our credentials is insecure, however. We shouldn't store production secrets in plaintext, where they are tracked by git and accessible to anyone reading the codebase. Fortunately, Rails provides a way to securely store credentials.

In your shell, run EDITOR=vim bin/rails credentials:edit to unencrypt and open the credentials file in vim. Hit the 'i' key on your keyboard to switch to insert mode. Add a new section that looks like this:

stripe:
  secret: your-secret-key
  public: your-public-key

Next, save the file and exit vim. There are a few ways to do this, but my favorite is by hitting the escape key (to leave insert mode) and then typing :wq, followed by the enter key. If you want to know more about how Rails handles encrypted credentials, this is a good resource.

Now that the credentials are safely stored, swap out the insecure initializer code with this:

Stripe.api_key = Rails.application.credentials[:stripe][:secret]

Linking to Stripe Checkout From the Product

There are a few different ways we can leverage Stripe for payments, but a great option is Stripe Checkout. Stripe Checkout is a hosted payment page, meaning you don't create any of the user interface for the actual transaction. A user will click a button, be redirected to Stripe, and then redirected back to your application after the payment goes through. This makes managing context a bit of a challenge, but it removes the burden of creating a payment form and lets Stripe do a lot of work to maximize conversions. You can easily accept payment types beyond cards, including ACH transactions or many international payment methods.

To begin, create a controller to handle the checkout process. Create app/controllers/checkout_controller.rb. In it, we'll write a method to handle the creation of a checkout session.

def create
  @session = Stripe::Checkout::Session.create({
    success_url: root_url,
    cancel_url: manage_posts_url,
    payment_method_types: ['card'],
    line_items: [{
        name: "Roommate Posting",
        amount: 2000,
        currency: "usd",
        quantity: 1
    }],
    mode: 'payment',
    metadata: {post_id: params[:post_id]},
    customer_email: current_user.email,
    success_url: manage_posts_url,
    cancel_url: manage_posts_url
  })

  respond_to do |format|
    format.js
  end
end

This creates one Checkout Session with the necessary context for Stripe to do its magic. We pass along a single line item, which we hard-coded here since our application only has one product. But this would be an easy place to use a passed parameter to customize the checkout for a particular product. The "Amount" variable is the cost of the product in cents. It's also worth mentioning that we pass in the current user's email for simplicity's sake. The last thing that's important to call out is that we pass along the id of a post as metadata. This will make it easy for us to fulfill our user's purchase in the web hooks section!

Next, we'll obviously need a way for the method to be called.

In config/routes.rb, add the following line to create a route to the controller.

post 'checkout/create' => 'checkout#create', as: "checkout_create"

Next, we'll add a button to submit payment for any post in the manage posts view. In app/views/manage.html.erb, add an extra column to the header by adding this last:

<th>Payment</th>

Also, switch <th colspan="3"></th> to <th colspan="4"></th>.

Next, add another item to the table body for the payment button itself. As the fourth item in the body, add the following:

<td><%= button_to "Submit Payment", checkout_create_path, params: {:post_id => post.id }, remote: true %></td>

All in all, the manage posts view now looks like this:

<p id="notice"><%= notice %></p>

<h1>Manage Posts</h1>
<%= link_to "New Post", new_post_url%>

<table>
  <thead>
    <tr>
      <th>Address</th>
      <th>Rent</th>
      <th>Content</th>
      <th>Payment</th>
      <th colspan="4"></th>
    </tr>
  </thead>

  <tbody>
    <% @posts.each do |post| %>
      <tr>
        <td><%= post.address %></td>
        <td><%= post.rent %></td>
        <td><%= post.content %></td>
        <td><%= button_to "Submit Payment", checkout_create_path, params: {:post_id => post.id }, remote: true %></td>
        <td><%= link_to 'Show', post %></td>
        <td><%= link_to 'Edit', edit_post_path(post) %></td>
        <td><%= link_to 'Destroy', post, method: :delete, data: { confirm: 'Are you sure?' } %></td>
      </tr>
    <% end %>
  </tbody>
</table>

<br>

<%= link_to 'New Post', new_post_path %>

JavaScript

Don't forget to add the Stripe JavaScript package include to app/views/application.html.erb with this line:

<script src="https://js.stripe.com/v3/"></script>

Next, you'll need to add a new file (and directory!) in app/views/checkout/create.js.erb. In this file, just add the following to allow the checkout session to be created upon clicking the button with the help of the controller.

var stripe = Stripe("<%= Rails.application.credentials[:stripe][:public] %>")

stripe.redirectToCheckout({
    sessionId: '<%= @session.id %>'
}).then(function (result) {
    console.log(result.error_message)
});

Setting Up Webhooks

Now, we have a way for users to pay for posts! However, we don't have a way for the application to know whether a post is paid for or activate it when it is. To start, add a Boolean to the post model to indicate whether a post has been paid for. To use the Rails migration generator, run the following:

rails g migration AddPaymentDetailsToPost

Open up this migration and add the following line to it:

add_column :posts, :is_paid, :boolean, :default => false

This adds an attribute/column to the posts table/model called is_paid. This attribute is a Boolean that defaults to false, which means that whenever a post is created, it is marked as having not been paid for. When a post is paid for, we'll manually flip the Boolean. But first, run the migration you just wrote with:

rails db:migrate

Because payments don't instantly process, we can't rely on a successful API response from Stripe to determine that a job has been paid for. Instead, we can indicate an endpoint on our application for Stripe to make a post request when a payment finishes processing. This process is generally referred to as webhooks and is actually simpler than it sounds!

To start, create a new controller in app/controllers/ called WebhooksController. In this app/controllers/webhooks_controller.rb, write the following:

class WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    event = nil

    begin
    event = Stripe::Webhook.construct_event(
      payload, sig_header, Rails.application.credentials[:stripe][:webhook]
    )
    rescue JSON::ParserError => e
      status 400
    return
    rescue Stripe::SignatureVerificationError => e
      # Invalid signature
      puts "Signature error"
      p e
      return
    end

    # Handle the event
    case event.type
    when 'checkout.session.completed'
      session = event.data.object
      post = Post.find_by(id: session.metadata.post_id)]
      post.is_paid = true
      post.save!
    end

    render json: { message: 'success' }
  end
end 

Other than the necessary boilerplate, this method will hand a specific event type, called checkout.session.completed. All that's left on the Rails side of things is to add the route to config/routes.rb:

resources :webhooks, only: [:create]

Finally, you'll need to connect your Stripe account to this endpoint in the Stripe dashboard itself. Because you'll be supplying a URL to the Stripe dashboard, this will not work locally! You must deploy this to an internet-accessible endpoint for webhooks to function properly.

Return to "Developers" on the Stripe dashboard , but this time, select "Webhooks" on the left panel. In the "Endpoints" section, click "Add Endpoint". Supply your applications URL appended with /webhooks and assign it the event "checkout.session.completed".

And that's it for Stripe integration! The last practical step for our mock application is to have the index page only display posts that have been paid for. This can easily be done in the index method of app/controllers/posts_controller.rb. Change the method to this:

def index
  @posts = Post.where(:is_paid => true)
end

Now, users can create a post, pay for it with Stripe checkout, and have it automatically displayed on the application's homepage!

Switching to Production

Because your webhooks only work when deployed to a live site, it's worthwhile to discuss changes needed for deployment. For starters, your communication must be HTTPS for Stripe checkout to work. If you're experiencing any issues, check the JavaScript console for some hints from Stripe.

There's a good chance that in your initial setup, you used Stripe's test keys. If you did, the API Keys section of the Stripe dashboard will look something like this.

A screenshot of Stripe dashboard API Keys A screenshot of Stripe dashboard API Keys

Simply click the toggle next to "viewing test data" to reveal your production secret and public keys. You'll need to open the Rails credentials manager in the same way as before and replace the test keys with this for your application to process live data. The test environment is helpful, however, for running test transactions with fake credit card numbers. If you have more than one environment or plan on switching this often, it's worthwhile to create another key/value pair in the credentials manager and dynamically use it in your code.

Conclusion

We've spent a lot of time creating our example application and not too long integrating Stripe. Thanks to Stripe checkout, we pass off a lot of responsibility, including the user interface, saving us plenty of code. This creates a consistent experience for users, given Stripe's widespread adoption in the online marketplace. Stripe's mission is to increase the GDP of the internet, and they need applications like yours to do that. Because of this, they're always looking for even easier ways for you to process payments. While Stripe Checkout is fairly easy to implement, they've recently thought of an even faster way.

Stripe Payment Links is a brand-new product from Stripe that has the potential to make our Checkout integration almost unnecessary. All we really did was redirect the user to a hosted checkout page, and a simple link has the potential to make that even easier. Payment links are a no-code solution that seems to be directed at retailers but could potentially work for this use case. Regardless, Stripe Checkout still boasts a bit more flexibility and adoption in the marketplace, so it's important to fully understand.

One thing many businesses struggle with is handling taxes, especially internationally. Stripe recently introduced a new feature to automatically handle this for businesses, but it's still in beta. Once available, it should greatly reduce the accounting burden of anyone using Stripe for payment processing!

Stripe's documentation and existing articles directed at Ruby on Rails lean towards subscriptions rather than one-time purchases. Subscriptions are recurring charges attached to a user and a product. On Stripe's end, the implementation looks a lot like one-time purchases. On the application side, however, we would have to integrate subscription management, allow the user to manage their subscription, and periodically check subscription status. This article focused on one-time purchases, but there's plenty of resources in the community should you need to manage subscriptions.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Jeffery Morhous

    Jeff is a Software Engineer working in healtcare technology using Ruby on Rails, React, and plenty more tools. He loves making things that make life more interesting and learning as much he can on the way. In his spare time, he loves to play guitar, hike, and tinker with cars.

    More articles by Jeffery Morhous
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial