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