Trello API Webhooks + Rails: Receiving Payloads with Custom Middleware

Trello is a hugely popular project organization tool, and my team uses it every day to plan development projects, lessons for our students and more.

However, if you're like me, it doesn't matter how many lists you make if you then forget to look at the list and do the things. For me, the only things I'm guaranteed to interact with each morning are my Gmail account and Slack.

After forgetting lots of things, much to the chagrin of my teammates, I began to wish that Trello could send me a Slack message and an email each morning, reminding me of any outstanding cards assigned to me.

Usually, if I wish for something long enough, it comes true. Just kidding. But, if I wish for something long enough, I will probably get up and build it. So, I began building a Rails app that would integrate with Trello and send my team daily reminders, as well as alert team members via Slack and email, whenever a card is assigned to them, or whenever there has been a change in a card assigned to them.

That second feature, the real-time updates, required working with the Trello Webhooks API, an experience I found particular and frustrating enough to walk through here.

Let's get started.

Step 1: Register with the Trello API

To register your app with the Trello API, and receive your developer key and secret, click here. You'll see your key right on top of the page, but you'll have to scroll down for the secret.

Step 2: Authorize Your User with OmniAuth Trello

The Trello API uses OAuth to authenticate a user and expose the authenticated user's OAuth token. All authenticated requests to the Trello API must include this token.

Once you get your key and secret, add them to your environment using a tool like Dotenv or Figaro, to ensure that you don't expose your secrets when you push to GitHub.

I used the OmniAuth Trello gem, created by Josh Rowley, in order to enact the Trello OAuth flow in my Rails app.

OmniAuth Trello

First, add the gem to your Gemfile:

gem 'omniauth-trello'

Then, configure OmniAuth Trello:

# config/initializers/omniauth.rb

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :trello, ENV['TRELLO_KEY'], ENV['TRELLO_SECRET'], 
    app_name: "your app name", scope: 'read,write,account', 
    expiration: 'never'
end

Then, define your callback URL:

# config/routes.rb

...
get '/auth/:provider/callback', to: 'sessions#create'

Lastly, create a link for users to log in. It should look like this:

# app/views/sessions/new.html.erb

<%= link_to 'Sign in with Trello', '/auth/trello' %>

When a user clicks that link, it will send the initial request to Trello, with your application's key and secret. Trello will authenticate the user, and send a payload back to your app's callback URL, with info describing that current user.

We mapped our callback URL to the Sessions Controller #create action. There, we can access our current user's information, sent to us by Trello, like this:

# app/controllers/sessions_controller.rb

...

def create
  request.env["omniauth.auth"]
    # => {uid: <user trello id>,
          info: 
          {
             email: "sophie@email.com",
             nickname: "sophiedebene"
           },
           credentials: 
           {
             token: <user token>,
             secret: <user secret>
           }
          }
           
end

Use this information to find and authenticate the corresponding user from your database. Make sure to persist the user's token and secret. I chose to persist the user's token and secrets a as the trello_oauth_token and trello_secret attributes on my users.

Now that we can authorize our users and persist their Trello OAuth tokens and secrets, let's set up our Webhooks.

Step 3: Configure the Trello Client with the Ruby Trello Gem

In order to manage my app's interactions with the Trello API, I used the Ruby Trello gem. However, I couldn't quite get the Webhook portion of the gem working. So, here we will set up and configure the gem, but we will use it slightly differently that the documentation shows when interacting with Trello Webhooks.

First, add the gem to your Gemfile and bundle install.

gem 'ruby-trello'

I placed by Trello API code in a special adapter that I build to contain external API-specific code.

Create a directly under app called adapters. Define a file, adapter.rb. Here we will define out Adapter module, which will house our TrelloWrapper class, and later on other API wrapper classes, like the class I will later build to interact with the Slack API.

# app/adapters/adapter.rb

module Adapter

  class TrelloWrapper

    def self.configure_client(current_user)
      Trello.configure do |config|
        config.consumer_key = ENV["TRELLO_KEY"]
        config.consumer_secret = ENV["TRELLO_SECRET"]
        config.oauth_token = current_user.trello_oauth_token
        config.oauth_token_secret = current_user.trello_secret
      end
    end

  end
end

So far, our class is pretty simple. It has a method, .configure_client, that configures the Trello client that the gem gives us access to. We will build additional methods that wrap this configured Trello client to make API requests shortly.

So, when should we configure the Trello client? I think we should do so immediately after logging in our user. Then, our client is ready to go for whenever we make requests to the Trello API.

# app/controllers/sessions_controller.rb
  ...

  def create
    user = User.find_or_create_from_omniauth(auth)
    if user
      log_in(user)
      Adapter::TrelloWrapper.configure_client(user)
      redirect_to root_path
    else
      redirect_to login_path
    end
  end

Great, now that our Trello client is authorized, and accessible for future API calls, we're ready to build out the user flow for adding Trello boards and Webhooks to our app.

Step 4: Adding Trello Boards

We'll assume our app has not only a User model, but also a TrelloBoard model and a Webhook model. Users will log in, and then add their Trello boards to the app by filling out a form that prompts them for the board's ID, taken from the board URL:

This is the board's "short ID", and we'll use it to make a request to the Trello API for the board's Trello ID. This ID is necessary for us to make that final API request to add a Webhook to the board.

So, a user will fill our a form for a new board with that board's name and short ID. We'll create a new TrelloBoard in our own application, using our Trello client to ask the Trello API for the Trello ID of this board.

# app/controllers/trello_boards_controller.rb

  ...
  def create
    board = TrelloBoard.new(trello_short_id: 
      board_params[:trello_short_id], name: 
      board_params[:name])
    # get the board's Trello ID by requesting it from the 
      Trello API
    if board.save
     # add a webhook to the board
    else
      # some kind of flash error message
    end
  end

  private
    def board_params
      params.require(:board).permit(:trello_short_id, :name)
    end

Here, we've begun to build out our TrelloBoards#create action. First, we create a new TrelloBoard, using the information the user supplied via the form. But, before we save the board, we need to get its Trello ID from the API. How do we get the Trello ID? We need to make the following request using the Trello client, available to us from the Ruby Trello gem:

Trello::Board.find(<short id>)

However, we want to wrap this line in our Adapter::TrelloWrapper class, to keep our API-specific code nice and organized.

# app/adapters/adapter.rb

module Adapter

  class TrelloWrapper

    def self.configure_client(current_user)
      Trello.configure do |config|
        config.consumer_key = ENV["TRELLO_KEY"]
        config.consumer_secret = ENV["TRELLO_SECRET"]
        config.oauth_token = 
          current_user.trello_oauth_token
        config.oauth_token_secret = current_user.secret
      end
    end

    def self.get_board_info(short_id)
      Trello::Board.find(short_id)
    end

  end
end

We'll use this new .get_board_info method to get the board's Trello ID. Then we can save the new board.

# app/controllers/trello_boards_controller.rb

  ...
  def create
    board = TrelloBoard.new(trello_short_id: 
      board_params[:trello_short_id], name: 
      board_params[:name])
    board_info= Adapter::TrelloWrapper.
                  get_board_info(board.trello_short_id)
    board.trello_id = board_info["attributes"]["id"]
    if board.save
     # add a webhook to the board
    else
      # some kind of flash error message
    end
  end

  private
    def board_params
      params.require(:board).permit(:trello_short_id, :name)
    end

Great! Now we're ready to add our Webhook.

Step 5: Adding Webhooks

First of all, what is a Webhook?

not this kind of hook

Webhooks are "user-defined HTTP callbacks". They are usually triggered by some event, such as pushing code to a repository or a comment being posted to a blog. When that event occurs, the source site makes an HTTP request to the URI configured for the webhook.
–– Wikipedia

In other words, we can "hook" into certain events on Trello, and tell Trello to send a payload of information describing that event to a pre-defined callback URL within our application.

Let's do it!

We know we want to add a Webhook to a given Trello board, after that board is added to our app by one of our users. In order to make that "create a webhook" request to the Trello API, I had to manually make the web request. The Webhook-creation methods available through the Ruby Trello gem wouldn't work for me.

How To Send a "create webhook" request to the Trello API

In order to fire off this request to the Trello API, we need to make a post request to this URL: https://api.trello.com/1/tokens/ with the following params:

  • The current user's Trello OAuth token
  • Your app's Trello API key

The POST request should also contain the following query:

{ description: "your description", callbackURL: <your callback URL>, idModel: <trello id of the board to which you want to add the Webhook> }

What is your callback URL? This is the URL to which Trello will send the payload, whenever the Webhook is triggered. If we add a Webhook to a Trello board, the hook will be triggered by any event within that board--a new list or card or a change to a list or card.

In order to remain organized, I've built out a WebhooksController, with a receive action. So, my callback URL will be: http://localhost:3000/webhooks/receive.

Lastly, the POST request should have a header of

{"Content-Type" => "application/x-www-form-urlencoded"}

Now that we know what our POST request should look like, let's build a method in Adapter::TrelloWrapper to wrap this request:

# app/adapters/adapter.rb

module Adapter

  class TrelloWrapper

    CALLBACK_URL = "http://localhost:3000/webhooks/recieve"

    def self.configure_client(current_user)
      Trello.configure do |config|
        config.consumer_key = ENV["TRELLO_KEY"]
        config.consumer_secret = ENV["TRELLO_SECRET"]
        config.oauth_token = 
          current_user.trello_oauth_token
        config.oauth_token_secret = current_user.secret
      end
    end

    def self.get_board_info(short_id)
      Trello::Board.find(short_id)
    end

    def self.create_webhook(trello_board_id)
      HTTParty.post("https://api.trello.com/1/tokens/
        #{Trello.client.trello_oauth_token}/webhooks/?key=
        #{ENV["TRELLO_KEY"]}",
        :query => { description: "test", callbackURL: 
        CALLBACK_URL, idModel: trello_board_id },
        :headers => { "Content-Type" => "application/x-www-
        form-urlencoded"})
    end
  end
end

Note that I'm using HTTParty, which I've included in my Gemfile.

How to Set Up Your Webhooks Controller

In order to successfully create a Webhook on a Trello board, we need our app to respond to a HEAD request to our specified callback URL, with a headless response of 200. This is a request that Trello will fire exactly once, when you first try to create the Webhook. The Webhook will only be successfully created if your callback URL responds with a 200 response to this request. Basically, Trello is trying to ensure that your callback URL is up and running, before completing the Webhook creation process.

This is a little tricky, since our callback URL should also handle the POST request, containing the Webhook event payload, that Trello will send in the future, whenever the hook is triggered by an event on our board.

So, we'll have to route two different kinds of requests to the same callback URL.

# config/routes.rb
  ...
  get "/webhooks/receive", to: "webhooks#complete"
  post "/webhooks/receive", to: "webhooks#receive"

Here, we are routing the HEAD request, which Rails will interperate as a GET request, to WebhooksController#complete, and any POST requests to our callback URL will be routed to the #WebhooksController#receive action.

Let's take a look at those two actions now:


class WebhooksController < ApplicationController
  skip_before_action :authenticate
  skip_before_action :verify_authenticity_token
  
  def complete
    return head :ok
  end

  def receive
    # coming soon!
  end

Note that we skip authentication and skip authenticity token verification, since the request will be coming from an external service, i.e. the Trello Api.

Now that our #complete action is up and running, our Webhook-creation POST request will be successful.

# app/controllers/trello_boards_controller.rb

  ...
  def create
    board = TrelloBoard.new(trello_short_id: 
      board_params[:trello_short_id], name: 
      board_params[:name])
      board_info= Adapter::TrelloWrapper
                   .get_board_info(board.trello_short_id)
      board.trello_id = board_info["attributes"]["id"]
    if board.save
      webhook_info = Adapter::TrelloWrapper
                      .create_webhook(board.trello_id)
      # create an save a Webhook record to our own database
      # using the info describing our new Trello Webhook!
      Webhook.create(trello_id: webhook_info["id"], 
          description: webhook_info["description"], 
          active: true, 
          callback_url: webhook_info["callbackURL"], 
          board_id: board.id,
          trello_model_type: "Board")
    else
      # some kind of flash error message
    end
  end

  private
    def board_params
      params.require(:board).permit(:trello_short_id, :name)
    end

Note: This is A LOT of code for our poor #create action. I highly recommend refactoring some of this into various services. I built a BoardGenerator service to wrap up the board creation code, and a WebhookGenerator service to wrap up the Webhook creation code.

Now that our Webhook is created, we have to ensure that our Webhooks#receive action can handle the payload that Trello will send whenever the hook is triggered by an event like a new list or card being created.

Step 7: Receiving Trello Webhook Payloads

Now that our Webhook is set up, we'll receive a payload describing the event that triggered the action, anytime the hook is triggered. Our payload will be sent via POST request to our callback URL. We routed that request to Webhooks#receive.

The payload that Trello will send looks like this:

{"model"=>
  {"id"=>"578251f059bce751763c9175",
   "name"=>"myboard",
   "displayName"=>"My Board",
   "desc"=>"great Trello board",
   "descData"=>nil,
   "url"=>"https://trello.com/b/39012u/myboard",
   "website"=>nil,
   "logoHash"=>nil,
   "products"=>[],
   "powerUps"=>[]},
 "action"=>
  {"id"=>"5782c8d38f40552d7f482815",
   "idMemberCreator"=>"556478763b736269c0299f54",
   "data"=>
    {"board"=>
      {"name"=>"My Board", 
       "id"=>"578251f059bce751763c9175"},
     "list"=>
      {"shortLink"=>"eKwu4f29",
       "name"=>"Things To Do",
       "id"=>"5782c8d38f40552d7f482812"}},
   "type"=>"addListToBoard",
   "date"=>"2016-07-10T22:14:43.274Z",
   "memberCreator"=>
    {"id"=>"556478763b736269c0299f54",
     "avatarHash"=>nil,
     "fullName"=>"Sophie DeBenedetto",
     "initials"=>"SD",
     "username"=>"sophiedebenedetto1"}},
 "controller"=>"webhooks",
 "action"=>"receive",
 "webhook"=>{}}

example payload describing a new list created on a board

If you look at the above payload very carefully, you'll notice a BIG problem. Trello sends a payload with a key of "action", which contains all the data we need about our event, in this case the new list creation.

However, Rails will add a key of "action", to all params, and set it to a value of the controller action that you are hitting. So, in its current form, Rails will overwrite our "action" payload key, eliminating all the info we need. O no!

How can we get around this? With custom middleware!

Defining Our Custom Middleware

We need to intercept the Trello payload, and replace the key of "action", with a key of another name, so that Rails will not overwrite it.

Let's do it!

First, define a file in lib/middlewares called trello_params_handler.rb.

Recall that in order to utilize that middleware, we have to tell our Rails app about it:

# config/application.rb

...
require_relative '../lib/middlewares/trello_payload_params_handler.rb'

...

module Flack
  class Application < Rails::Application
    config.web_console.whitelisted_ips = ['107.23.104.115', '54.152.166.250', '107.23.149.70']
    config.middleware.use 'TrelloPayloadParamsHandler'
  end
end

*Gotcha: I also found I had to whitelist the IPs above in order to receive payloads from Tello.

Now let's build out our middleware.

Remember that middlewares need to have a #call method. Our call method should operate on the existing params and replace the "action" key with a key called "event".

class TrelloPayloadParamsHandler
  def initialize(app)
    @app = app
  end

  def call(env)
    params = env["rack.input"].gets
    if params && params["action"]
      result = JSON.parse(params)
      if result["action"]
        result["event"] = result["action"]
        result.delete("action")
        env["action_dispatch.request.request_parameters"] = 
          result
      end
    end
    @app.call(env)
  end
end

Now, when we view params in our Webhooks#receive action, we'll see this:

{"model"=>
  {"id"=>"578251f059bce751763c9175",
   "name"=>"secondtestteam",
   "displayName"=>"Second Test Team",
   "desc"=>"another test team b/c webhooks suck",
   "descData"=>nil,
   "url"=>"https://trello.com/secondtestteam",
   "website"=>nil,
   "logoHash"=>nil,
   "products"=>[],
   "powerUps"=>[]},
 "event"=>
  {"id"=>"5782c8d38f40552d7f482815",
   "idMemberCreator"=>"556478763b736269c0299f54",
   "data"=>
    {"organization"=>
      {"name"=>"Second Test Team", 
       "id"=>"578251f059bce751763c9175"},
     "board"=>
      {"shortLink"=>"eKwu4f29",
       "name"=>"my best board ever",
       "id"=>"5782c8d38f40552d7f482812"}},
   "type"=>"addToOrganizationBoard",
   "date"=>"2016-07-10T22:14:43.274Z",
   "memberCreator"=>
    {"id"=>"556478763b736269c0299f54",
     "avatarHash"=>nil,
     "fullName"=>"Sophie DeBenedetto",
     "initials"=>"SD",
     "username"=>"sophiedebenedetto1"}},
 "controller"=>"webhooks",
 "action"=>"receive",
 "webhook"=>{}}

Now, we can use this info to make create and persist lists and cards, and to send real-time updates to our users via Slack and Gmail.

Conclusion

Of course, there is much more to be done in building out this application. The purpose of this post, however, is to shed some light on:

  • Authentication in Rails with Trello
  • Creating Trello Webhooks with Rails
  • Receiving Trello Webook payloads in a Rails application, with the help of custom middleware.

Thanks for reading and feel free to shoot me a comment with any questions/feedback. Happy coding!

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus