A good start for integrating Trello Webhooks with Laravel

After a long silence, I am back with some more tips. I have a good reason, though. For the past year, I've been working on an automation system written in Laravel for a London-based company. Its operation heavily relies on exchanging data between Trello and Hubspot. Among others, Trello Webhooks are the star of the show.

If you would like to jump straight to the code, here is the Github repo.

For those who are looking for brief explanation here it is.

Preparation

You have a trello account, right?

If you don't create one.

App key, token and OAuth secret

You need to obtain them here.

.env

Save them into Laravel's .env file

TRELLO_API_KEY=
TRELLO_API_TOKEN=
TRELLO_OAUTH_SECRET=

services.php

And then into the config/services.php file.

<?PHP

return [
    'trello' => [
        'key' => env('TRELLO_API_KEY'),
        'token' => env('TRELLO_API_TOKEN'),
        'oauth_secret' => env('TRELLO_OAUTH_SECRET'),
    ],
];

Valid callback URL

You need a valid callback URL that Trello uses to post its webhook request. When webhooks are created, Trello will try to send a GET request to that URL. If invalid, the webhook won't be made.

Here are some tips if you are working locally:

      - https://ngrok.com
      - https://expose.dev
      - Laravel Valet for Mac or Linux share option `valet share`

Save the url in the .env file.

APP_URL=

How do I obtain the card's idModel?

It's simple. Create a card and click on it. Then, append .json at the end of the URL string. That's it.

Dealing with webhooks in Laravel?

How to create it?

You can create a webhook for most Trello entities, e.g. card, board, list, etc. Keep in mind that idModel refers to them. For the sake of this example, let's use https://larave-webhook-demo.com/ as base_url. You'll need to replace it with one of the previously mentioned options. The URL should then looks something like this http://xxxx/webhooks/trello.

Let's look at how you can create it with the Laravel command.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;

class CreateWebhookCommand extends Command
{
    protected $signature = 'trello-webhooks:create {idModel}';


    protected $description = 'create trello webhook';


    public function handle()
    {
        $authHeader = 'OAuth oauth_consumer_key="';
        $authHeader .= config('services.trello.key');
        $authHeader .= '",oauth_token="'.config('services.trello.token').'"';

        $response = Http::withHeaders([
            'Authorization' => $authHeader,
        ])->post('https://api.trello.com/1/webhooks', [
            'idModel' => $this->argument('idModel'),
            'description' => 'webhook description',
            'callbackURL' => config('app.url').'/webhooks/trello',
        ]);

        if ($response->status() !== Response::HTTP_OK) {
            $this->error('Webhook not created!');

            return Command::FAILURE;
        }

        $this->info('Webhook created!');

        return Command::SUCCESS;
    }
}

As mentioned, you need a GET route. I put it in routes/webhooks.php file. Don't forget to register webhooks.php in the RouteServiceProvider.php.

#routes/webhooks.php

Route::middleware([VerifyTrelloIPsMiddleware::class])
    ->get('trello', function () {

    return response()->noContent(Response::HTTP_OK);

})->name('get.trello');

How can you delete it?

The code is even duller than the one for creating it.

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;

class DeleteWebhookCommand extends Command
{
    protected $signature = 'trello-webhooks:delete {idModel}';

    protected $description = 'delete trello webhook';


    public function handle()
    {
        $authHeader = 'OAuth oauth_consumer_key="';
        $authHeader .= config('services.trello.key');
        $authHeader .= '",oauth_token="'.config('services.trello.token').'"';

        $response = Http::withHeaders([
            'Authorization' => $authHeader,
        ])->delete('https://api.trello.com/1/webhooks/'.$this->argument('idModel'));

        if ($response->status() !== Response::HTTP_OK) {
            $this->error('Webhook not deleted!');

            return Command::FAILURE;
        }

        $this->info('Webhook deleted!');

        return Command::SUCCESS;
    }
}

How to fetch all webhooks?

Please return a class and not a plain PHP array. I made the mistake of returning an array in one of my projects, and now I have to deal with keys such as customText2 and customText21 (does anybody know by heart what customText2 is? I don't, even though I've been working on this project for months). Having meaningful properties would be much more beneficial.

<?php

namespace App\Console\Commands;

use App\DataObjects\WebhookData;
use Illuminate\Console\Command;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Http;

class FetchAllWebhooksCommand extends Command
{

    protected $signature = 'trello-webhooks:all';

    protected $description = 'fetch all trello webhooks';

    public function handle()
    {
        $authHeader = 'OAuth oauth_consumer_key="';
        $authHeader .= config('services.trello.key');
        $authHeader .= '",oauth_token="'.config('services.trello.token').'"';

        $response = Http::withHeaders([
            'Authorization' => $authHeader,
        ])->get('https://api.trello.com/1/tokens/'.config('services.trello.token').'/webhooks');

        if ($response->status() !== Response::HTTP_OK) {
            $this->error('Failed to fetch webhooks.');

            return Command::FAILURE;
        }

        $webhookRows = WebhookData::collection($response->json())
            ->only('id', 'idModel', 'active', 'consecutiveFailures');

        $this->table(
            [
                'id',
                'idModel',
                'active',
                'consecutiveFailures',
            ],
            $webhookRows,
        );

        $this->line('Total number of webhooks: '.count($response->json()));

        return Command::SUCCESS;
    }
}

What should you do when Trello dispatch a webhook?

What you need is a POST route. I just shoved it under the webhooks.php route file. But a dedicated controller would make more sense out in the wild. Just a quick note here. Please utilise Laravel Queues here and return the 200 status code straight away. Trello will be eternally grateful.

Route::middleware([VerifyTrelloWebhookSignatureMiddleware::class, VerifyTrelloIPsMiddleware::class])
    ->post('trello', function (Request $request) {

    info($request->getContent());

    // ideally, a job is dispatched here, and the OK status is returned straight away

    return response()->noContent(Response::HTTP_OK);
})->name('post.trello');

Don't forget to verify the incoming Trello request

You are worried about the authenticity of the webhooks, right? The best place for that is middleware. I use two middlewares for that VerifyTrelloSignatureMiddleware and VerifyTrelloIPsMiddleware.

VerifyTrelloSignatureMiddleware

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class VerifyTrelloWebhookSignatureMiddleware
{

    public function handle(Request $request, Closure $next)
    {
        $hashed = base64_encode(
            hash_hmac(
                'sha1',
                $request->getContent().route('post.trello'),
                config('services.Trello.oauth_secret'),
                true
            )
        );

        abort_if($request->header('X-Trello-Webhook') !== $hashed, Response::HTTP_BAD_REQUEST, 'Bad Webhook.');

        return $next($request);
    }
}

VerifyTrelloIPsMiddleware

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class VerifyTrelloIPsMiddleware
{
    const IP_START = '18.234.32.224';

    const IP_END = '18.234.32.239';


    public function handle(Request $request, Closure $next)
    {
        if ($this->insideRange(ip2long($request->ip()))) {
            return $next($request);
        }

        abort(Response::HTTP_BAD_REQUEST, 'Bad incoming trello request.');
    }


    protected function insideRange($ip)
    {
        return $ip >= ip2long('18.234.32.224') && $ip <= ip2long('18.234.32.239');
    }
}

Few bits and bobs about testing

Protected properties

I like to use protected properties They are easy to use in different stages of the test phase (arrange, act, assert) and reference, especially in the callbacks such as Http::assertSendInOrder. Finally, because they are at the top, you can immediately see the important stuff in the tests.

AssertSentInOrder

When dealing with HTTP fakes, my preferred assertion method is assertSendInOrder. You get two assertions in one call. AssertSendInOrder counts the number of requests and their order. Bonus tip: this assertion is messy if multiple HTTP requests leverage callbacks inside. I tend to create a protected method with a callback inside. I use convention assert...Request.

Testing middlewares

I unit test middlewares. In the future test, I assert that the route has it. In all other feature tests inside the same class I use the withoutMiddleware method to skip them. How to check if middleware is applied to the route, I hear you ask. Here is how.

If interested, peek inside a tests folder; you might find some additional nuggets of testing tips.

Conclusion

If you have any questions, you know where to find me. Thanks for staying with me on the journey of integrating Trello webhooks with Laravel.

Talk to you soon. (or maybe not) :)