How to test webhooks locally with Rails 6 & ngrok

 

Webhooks are HTTP requests sent from an external service which ping your application at a pre-defined URL, usually with some data in tow. You then use this data in your application as needed. More often than not they are POST requests. They are a mainstay of the modern web.

Webhooks are often used to keep data in sync. For example, a payment service such a Stripe sends out webhooks whenever an event happens; a new subscriber, a failed payment, etc. You then 'catch' this webhook, and use the data Stripe sends with the request to update your application.

As with so many things in modern web app development, Rails makes receiving webhooks a breeze. That said, building webhook-based functionality locally can be challenging. There are two main sticking points:

  • Webhooks come from external services, and you're working on a local machine with a variable IP address
  • Testing webhooks through controllers can quickly become bloated

Without further ado, let's dive in. Note that this guide assumes you're using Rails 6 and RSpec.

Install ngrok for local webhook testing

First challenge – webhooks are sent from external services, whereas development environments are on a local machine without a fixed address. Pushing code to a staging server for testing is an option, but it's a very slow process. Thankfully, there's a better way. Enter, ngrok.

Ngrok allows you to create a 'tunnel URL' to your local machine. This tunnel gives access a given port on your local machine from anywhere on the internet. It's ideal for working with webhooks.

The free version of ngrok gives random subdomains every time you start up. This means you'll need to change your webhook testing URLs every time you start ngrok. With the $5 / month plan you can set a custom subdomain, which bypasses this problem entirely. IMHO that's money well spent.

Once you've installed ngrok (and optionally set up your subdomain), it's time to fire 'er up:

$ ~/ngrok http -hostname=YOUR_HOSTNAME.eu.ngrok.io -region=eu 3001

Couple of things to note here. We're running ngrok on port 3001 as our Rails server is running there. If you have a custom ngrok domain, specify it with the -hostname flag. Finally, you have to specify a -region flag if you're outside the US.

If you don't have a custom domain, just remove the -hostname and -region and you'll get a temporary URL:

$ ~/ngrok http 3001

And with that, you're up and running. You also get a nifty dashboard to inspect incoming webhook requests at localhost:4040:

notion image

Add your ngrok host to Rails

Starting in Rails 6, you must give your ngrok tunnel URL permission to access your development environment. Add the following to your development.rb file (remember you must restart your server after editing development.rb for changes to take effect).

# config/environments/development.rb
Rails.application.configure do
	# ...
  config.hosts << 'YOUR_HOSTNAME.ngrok.io'
	# ...
end

Create your webhook route

As mentioned at the top, webhooks are almost always POST requests. We'll create a very simple route to handle an incoming webhook in our routes.rb file:

# config/routes.rb
Rails.application.routes.draw do

	# ...
  resources :webhooks, only: :create

end

This gives us a POST localhost:3001/webhooks route, but thanks to ngrok we can also access that route through our custom tunnel URL, POST https://YOUR_HOSTNAME.ngrok.io/webhooks. Next, let's set up our webhooks controller:

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

  skip_before_action :verify_authenticity_token

  # POST /webhooks
	def create
  end

end

We skip token authenticity verification to allow our webhook request through, as Rails will automatically block any incoming HTTP POST requests out of box.

Extract webhook logic into service objects

Test-driven development is a thing of beauty. But testing webhook functionality through controllers can quickly become bloated. Nobody wants a fat controller.

The solution is to extract webhook parsing into service objects (a.k.a. plain old ruby objects / POROs), then test the service objects.

Here's the skeleton of a basic webhook parsing spec/service. In a nutshell, it will parse a JSON payload, then perform a different action depending on the event type:

# app/services/webhook_parsing_service.rb
class WebhookParsingService

	attr_reader :errors

  def initialize(payload)
		@payload = payload
		@errors = []
	end

	def success?
		@errors.empty?
	end

	def call
    parse_json_payload
    filter_events if success?
    self
  end

  private

  def parse_json_payload
    @json = JSON.parse(@payload)
  rescue JSON::ParserError => e
    @errors << e.message
  end

  def filter_events
    if @json['type'] == 'some.event'
		  # ...
    elsif @json['type'] == 'other.event'
      # ...
    end
  end

end
 
# spec/services/webhook_parsing_service.rb
require 'rails_helper'

describe WebhookParsingService do

  context 'When an invalid object is provided' do
    it 'fails' do
      expect(WebhookParsingService.new('').call.success?).to be_falsy
    end
  end

  context 'When an valid JSON object is provided' do
    before do
      @service = WebhookParsingService.new(YOUR_JSON_STRING).call
    end

    it 'succeeds' do
      expect(@service.success?).to be_truthy
    end

		# ...

  end
end

Local webhooks FTW

Webhooks help you connect your application to a world of external services. Rails and ngrok help you build test-driven webhook functionality on your local machine. And seriously, go for the custom ngrok domain. It won't change your life, but it will make it that little bit easier.

 
James Chambers
Good morning. I'm James.

I send a twice-monthly newsletter about building indie software products. It's called Build Notes, and you can sign up below.

© 2014-2020 James Chambers