Building webhook endpoints with Rails

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.