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 -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 << ''
	# ...

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


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 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


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 = []

	def success?

	def call
    filter_events if success?


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

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

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

describe WebhookParsingService do

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

  context 'When an valid JSON object is provided' do
    before do
      @service =

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

		# ...


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-2021 James Chambers