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
:
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](http://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.