Building a Rails controller is simple and well documented. Follow the ‘Rails way’ and your life is easy. However, webhook endpoints require a different approach. In this post you’ll learn how to build maintainable and secure webhook endpoints.
Webhook route and controller
First you need a route. Typically external apps will issue POST requests when calling webhook endpoints. In this case we’re using a generic /webhooks
endpoint.
1
post '/webhooks', to: 'webhooks#create'
You’ll also need a controller/action to handle the webhook requests.
1
2
3
4
class WebhooksController < ApplicationController
def create
end
end
Conditional logic on params
The webhook controller/action needs some logic. For this scenario, imagine we want to create a user, based on the contents of the webhook request body. If the request body contains a username
, run some logic, if not, return an error.
1
2
3
4
5
6
7
8
9
10
11
12
class WebhooksController < ApplicationController
def create
if username = params.dig(:user, :username)
User.create!(username: username)
# some additional logic
render json: {}, status: :created
else
render json: {}, status: :unprocessable_entity
end
end
end
If the username is present then a User
record is created. There will likely be some additional logic to run, as indicated by the comment.
If the username is missing, the endpoint will return an unprocessable entity error.
Conditional logic on headers
Another way to receive data from webhook requests is via headers. In this example, the external service sends a X-Person-Event
header, indicating the user’s action. Here’s how you add conditional logic based on headers.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class WebhooksController < ApplicationController
def create
if request.headers['X-Person-Event'] == 'purchase'
Purchase.create!(amount: params.dig(:purchase, :amount))
# some additional logic
render json: {}, status: :created
elsif params.dig(:user, :username)
User.create!(username: params.dig(:user, :username))
# some additional logic
render json: {}, status: :created
else
render json: {}, status: :unprocessable_entity
end
end
end
Refactor using service object pattern
Proceeding in this way, the controller will become hard to maintain. The Service Object pattern is a good way to solve the problem. The idea is to abstract the logic, inside each conditional, into a PORO (plain old ruby object). This simplifies the controller and results in specialised, re-usable objects.
Here’s how the controller looks after the refactor.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class WebhooksController < ApplicationController
def create
if request.headers['X-Person-Event'] == 'purchase'
PurchaseHandlerService.call(params)
render json: {}, status: :created
elsif params.dig(:user, :username)
UserHandlerService.call(params)
render json: {}, status: :created
else
render json: {}, status: :unprocessable_entity
end
end
end
Building service objects is simple. We recommend the same pattern each time: a descriptive class name and a single public call
method. We also recommend adding a self.call
method to provide a nice shorthand. This allows callers to use the service directly without initializing.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PurchaseHandlerService
def self.call(params)
new(params).call
end
def initialize(params)
@params = params
end
def call
Purchase.create!(amount: params.dig(:purchase, :amount))
# some additional logic
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class UserHandlerService
def self.call(params)
new(params).call
end
def initialize(params)
@params = params
end
def call
User.create!(username: params.dig(:user, :username))
# some additional logic
end
end
Skip verify_authenticity_token
Before this controller can be deployed into production, you need to remove the CSRF check. By default, Rails controllers check for a CSRF token. External webhook requests won’t have this token, so the check must be skipped.
1
2
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
You may be wondering about the security implications of this and it’s a good point. Anyone can make requests to this endpoint, which is a problem. There are a few solutions, which we’ll cover briefly.
IP Whitelisting is where API providers publish one or more IP addresses, which webhook endpoints can expect to be called from. Using these addresses, you can add logic that checks the origin IP address of each incoming request. If a particular request does not originate from a whitelisted IP, ignore it.
Signing requests is a technique where API providers add a cryptographic hash to the request, typically in a header. The hash is generated using an algorithm (eg: HMAC), using a secret known to both the API provider and webhook recipient. You can add logic that authenticates the signature, using the secret. If the signature is missing or unauthorised, ignore it.