Integrating Stripe Webhooks in Ruby on Rails

Alvin CrespoAlvin Crespo
6 min read

Handling subscription payments is a common requirement for many modern web applications. Stripe is one of the most popular payment processing services, and their webhook system allows your application to respond to events happening within the Stripe ecosystem. In this post, I'll walk you through integrating Stripe webhooks into a Rails application to handle subscription updates.

What are Webhooks?

Before diving into the code, let's understand what webhooks are. Simply put, webhooks are automated messages sent from one application to another when certain events occur. In our case, Stripe will send notifications to our application when events like subscription updates happen. For example, if you are using Stripe’s built-in Customer Portal feature, you may want to be aware of any updates in your application.

The Story

For this walkthrough, let’s look at the following acceptance criteria:

As a user whose free trial has ended

I would like to upgrade to the Basic - Monthly plan in the Customer Portal

When I upgrade to the Basic - Monthly plan, I want to see that change in my user settings.

There is another set of related acceptance criteria:

As a customer support person, in the process of upgrading a persons subscription

I would like to upgrade their subscription in Stripes Dashboard

And when the customer logs in, they should see their subscription updated in their user settings.

Both these stories have something in common: an event has taken place outside the main application and our application needs to know about it.

The approach here then, is to do a simple integration with Stripes Webhooks. We’ll capture a specific event related to a subscription change on the user and then update our database.

Prerequisites

For this tutorial, you'll need:

  • A Ruby on Rails application

  • A Stripe account with API keys

  • Basic understanding of Rails models and controllers

Setting Up Your Models

Our application needs to track users and their subscriptions. We'll use three models:

  1. User - Represents a customer using our application

  2. Subscription - Represents a subscription plan in Stripe

  3. SubscriptionUser - A join model connecting users and subscriptions

# app/models/user.rb

class User < ApplicationRecord
  has_one :subscription_user
  has_one :subscription, through: :subscription_user

  # This method will be called by our webhook
  def self.update_subscription(stripe_subscription)
    user = User.find_by(stripe_id: stripe_subscription.customer)
    subscription = Subscription.find_by(stripe_price_id: stripe_subscription.plan.id)

    Rails.logger.error("Subscription not found for stripe_price_id: #{stripe_subscription.plan.id}") if subscription.blank?

    return if user.blank? || subscription.blank?

    user.subscription_user.update(
      subscription: subscription,
      stripe_product_id: stripe_subscription.plan.product,
      stripe_subscription_id: stripe_subscription.id
    )
  end
end

# app/models/subscription.rb

class Subscription < ApplicationRecord
  has_and_belongs_to_many :users
end

# app/models/subscription_user.rb

class SubscriptionUser < ApplicationRecord
  self.table_name = "subscriptions_users"

  belongs_to :user
  belongs_to :subscription
end
💡
Note that we have created a User#update_subscription class method that takes the subscription response from Stripe that we can then leverage to find the user, corresponding subscription and associate them on our join subscriptions_users table. Also, the stripe_subscription is the subscription object from Stripe.

Creating the Webhooks Controller

Next, we'll create a controller to handle incoming webhook event customer.subscription.updated from Stripe:

# app/controllers/webhooks_controller.rb

class WebhooksController < ApplicationController
  before_action :setup_event, only: [:stripe]
  before_action :setup_payload, only: [:stripe]
  before_action :setup_signature_header, only: [:stripe]
  before_action :setup_endpoint_secret, only: [:stripe]

  def stripe
    begin
      @event = Stripe::Webhook.construct_event(
        @payload, @signature_header, @endpoint_secret
      )
    rescue JSON::ParserError => _e
      render json: { error: "Invalid payload" }, status: 400 and return
    rescue Stripe::SignatureVerificationError => _e
      render json: { error: "Invalid signature" }, status: 400 and return
    end

    handle_event

    render json: { message: "Success" }, status: 200
  end

  private

  def setup_event
    @event = nil
  end

  def setup_payload
    @payload = request.body.read
  end

  def setup_signature_header
    @signature_header = request.env["HTTP_STRIPE_SIGNATURE"]
  end

  def setup_endpoint_secret
    @endpoint_secret = ENV["STRIPE_WEBHOOK_SECRET"]
  end

  def handle_event
    case @event.type
    when "customer.subscription.updated"
      handle_subscription_updated(@event.data.object)
    else
      Rails.logger.info("Unhandled event type: #{@event.type}")
    end
  end

  def handle_subscription_updated(subscription)
    User.update_subscription(subscription)
  end
end
💡
The controller is pretty straightforward here. The important piece is Stripe::Webhook.construct_event. This method creates an object that normalizes the JSON string sent over by Stripe. This creates an instance of Stripe::Event , we then tap into the data property. The data property is an instance of Stripe::Event::Data. From there we retrieve the Subscription object via the object property on data.

Configure Routes

Add the webhook endpoint to your routes:

# config/routes.rb

Rails.application.routes.draw do
  # other routes...
  post 'webhooks/stripe', to: 'webhooks#stripe'
end

Security Considerations

Stripe uses a signature verification system to ensure that webhook events are actually coming from Stripe. The Stripe::Webhook.construct_event method verifies this signature. You'll need to set the STRIPE_WEBHOOK_SECRET environment variable with your webhook signing secret from the Stripe dashboard.

Testing

Testing webhooks can be challenging since they involve external services. Here's a test example that mocks a Stripe webhook event:

# test/controllers/webhooks_controller_test.rb
require "test_helper"
require "mocha/minitest"

class WebhooksControllerTest < ActionDispatch::IntegrationTest
  SECRET = "whsec_test_secret"

  setup do
    @user = users(:user_in_free_trial)
  end

  test "handle_subscription_updated updates subscription_user when user exists" do
    payload = {
      id: "evt_123",
      object: "event",
      type: "customer.subscription.updated",
      data: {
        object: {
          customer: @user.stripe_id,
          plan: { id: "price_def456", product: "prod_12345678901" },
          id: "sub_123"
        }
      }
    }.to_json

    ClimateControl.modify STRIPE_WEBHOOK_SECRET: SECRET do
      assert_equal "Free Trial", @user.subscription.name

      post webhooks_stripe_url, params: payload, headers: compute_headers(payload)

      @user.reload

      assert_equal "Basic - Monthly", @user.subscription.display_name
    end
  end

  def compute_headers(payload)
    {
      "Content-Type": "application/json",
      "HTTP_STRIPE_SIGNATURE": compute_signature(payload)
    }
  end

  def compute_signature(payload)
    timestamp = Time.now
    timestamped_payload = "#{timestamp.to_i}.#{payload}"
    scheme = "v1"
    signature = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), SECRET, timestamped_payload)

    "t=#{timestamp.to_i},#{scheme}=#{signature}"
  end
end
💡
This test verifies that a users subscription is updated without having to mock Stripe::Webhook.construct_event, which is a common practice. However, it is a good idea to minimize the amount of mocking for such a critical piece of an application. To ensure the method runs smoothly, I’ve implemented a compute_signature, which generates a valid header value to check against. The generation of the signature itself can be found in Stripe’s library at Stripe::Signature#generate_header. More info can be found in the reference section below.

Common Issues and Solutions

  1. Missing Environment Variables: Ensure that your STRIPE_WEBHOOK_SECRET is correctly set in all environments.

  2. Incorrect Signature: Double-check that you're using the correct signing secret from Stripe.

  3. Outdated Stripe API Version: Make sure your Stripe gem is up to date.

  4. Event Duplication: Stripe may send the same event multiple times. Your code should be idempotent (able to handle the same event multiple times without side effects).

Conclusion

Integrating Stripe webhooks into your Rails application allows you to respond to payment events in real-time. This approach is more reliable than polling the Stripe API since it ensures you never miss an event. The implementation we've covered handles subscription updates, but you can easily extend it to handle other event types like payment_intent.succeeded or invoice.payment_failed . Remember to test your integration and keep it future proof by minimizing how much you mock the Stripe library. Happy coding!


References

The Subscription object

Class: Stripe::Event

Class: Stripe::Event::Data

customer.subscription.updated

stripe-ruby/test/stripe /webhook_test.rb

stripe-ruby/lib/stripe /webhook.rb#generate_header

0
Subscribe to my newsletter

Read articles from Alvin Crespo directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Alvin Crespo
Alvin Crespo

Hi, I’m Alvin! I write about tech, and love sharing what I learn. If you have any questions or suggestions, please reach out - I'm always up for a quick chat.