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.
If you don't create one.
You need to obtain them here.
Save them into Laravel's .env file
TRELLO_API_KEY=
TRELLO_API_TOKEN=
TRELLO_OAUTH_SECRET=
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'),
],
];
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:
Save the url in the .env file.
APP_URL=
It's simple. Create a card and click on it. Then, append .json
at the end of the URL string. That's 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');
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;
}
}
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 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');
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.
<?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);
}
}
<?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');
}
}
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.
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.
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.
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) :)