Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
0% found this document useful (0 votes)
10 views

consuming-apis-in-laravel-sample

Ash Allen, a freelance Laravel web developer, shares insights on integrating external APIs into web applications, emphasizing the importance of webhooks for asynchronous event handling. The document outlines techniques for consuming APIs, building webhook routes, and handling requests securely in Laravel, using Mailgun as a case study. It also discusses best practices for managing webhook responses and ensuring security against unauthorized requests.

Uploaded by

Decio Rocha
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
10 views

consuming-apis-in-laravel-sample

Ash Allen, a freelance Laravel web developer, shares insights on integrating external APIs into web applications, emphasizing the importance of webhooks for asynchronous event handling. The document outlines techniques for consuming APIs, building webhook routes, and handling requests securely in Laravel, using Mailgun as a case study. It also discusses best practices for managing webhook responses and ensuring security against unauthorized requests.

Uploaded by

Decio Rocha
Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 19

Introduction

Hey!

My name is Ash Allen, and I'm a freelance Laravel web developer based in the United Kingdom.

Over the past five years, I've worked on many projects that range from websites for small, local
businesses to large-scale enterprise applications for large companies.

During this time, I've been lucky enough to work with fantastic people and companies and learned a
lot from them. I've also worked on interesting projects and seen how different teams tackle different
problems.

If there's one thing I've noticed during this time, it's that most web applications eventually integrate
with an external API. Whether this is for simple things such as sending emails or more complex things
such as integrating with a third-party payment gateway, being able to lean on external systems to do
the heavy lifting for you can significantly benefit your application. It can allow you to focus on the
core features of your application and let external companies handle things you don't need to (and
don't want to) worry about. For example, instead of building a payment system for your new web
application, you can use Stripe to handle payments. This lets you focus more on building cool new
features and let Stripe worry about payment processing.

I've worked with many different APIs, including Stripe, Mailgun, Twilio, Marketo, Zapier, Twitter,
Vonage, GitHub, and many more. While working with these APIs, I've noticed common patterns and
best practices that I (or the team I'm working with) have been able to use. I've also seen mistakes and
oversights that came back to bite us later on.

In this book, we will cover the techniques I've learned and give you a better understanding of how to
consume APIs directly from your Laravel application.

We'll start by covering what an API is, the benefits they provide, the different types of APIs that you
might come across, and how to authenticate with them. We'll then cover code techniques I use to
make consuming APIs easier. We'll discuss things like final classes, readonly classes and
properties, composition over inheritance, interfaces, data transfer objects, and more.

After this, we'll cover how to consume an API from your Laravel application using Saloon. We'll discuss
things like making requests, OAuth2, caching responses, error handling, and testing your API
integrations.

12
Webhooks
Webhooks form an important part of API integrations. They allow you to receive data from an external
application when something happens, such as the successful delivery of an email or the placement of
a new order. Your application can use webhooks to react to events asynchronously and perform
actions based on them.

Depending on the API service you're interacting with, webhooks may sometimes be required to
complete your applications' features and API integration. For example, when using a payment
gateway such as Stripe, your application must accept webhooks sent from Stripe to be notified of
successful and failed payments.

In this section, we will look at what webhooks are, how they work, and how to handle them securely in
your Laravel application. We'll also look at real-life examples of how you might want to use webhooks.
After this, we'll look at how you can test that your application can handle webhooks securely and
robustly.

415
Building Webhook Routes
Now that we have a brief understanding of what webhooks are and how they can be used, let's look at
how we can build a webhook route in a Laravel application.

We'll build a webhook route that handles an API request sent from Mailgun when an email is
delivered. First, we'll focus on getting the webhook route working. Then we'll look at securing it to
prevent malicious users from sending fake requests to our application.

Imagine the workflow for sending an email is as follows:

1. The user performs an action, such as submitting a form, that triggers an email to be sent.
2. A new row in an email_logs table is created with the status of pending.
3. Your application sends an API request to Mailgun to send the email, passing the ID of the row in
the email_logs table as a "user variable" (we'll cover this in more depth later).
4. Mailgun handles sending the email to the correct recipient.
5. Mailgun sends an HTTP request to your application's webhook route, passing the ID of the row
in the email_logs table as a "user variable".
6. Your application updates the email status in the email_logs table to delivered.

For this section, assume steps 1-4 are completed. We'll solely focus on steps 5 and 6, which involve
building the webhook route.

What Will Be Sent

Before we touch any code, let's look at an example payload Mailgun will send to our webhook route.
This will help us understand how to implement the code in the controller, service class, and
middleware:

425
{
"signature": {
"token": "92e79f1325dc7f63b091cf4765e893937932f9faaf3fa35b2e",
"timestamp": "1679932336",
"signature": "541346f2701f6ab19b21e6ba7719be29c95bf95b605a643e3b139934581fa1d0"
},
"event-data": {
"id": "CPgfbmQMTCKtHW6uIWtuVe",
"timestamp": 1521472262.908181,
"log-level": "info",
"event": "delivered",
// ...
"message": {
"headers": {
"to": "Alice <alice@example.com>",
"message-id": "20130503182626.18666.16540@example.com",
"from": "Bob <bob@example.com>",
"subject": "Test delivered webhook"
},
"attachments": [],
"size": 111
},
"recipient": "alice@example.com",
"recipient-domain": "example.com",
// ...
"user-variables": {
"email_id": "123"
}
}
}

For the purposes of this book, I've removed some of the information in this webhook as the payload
contains a lot of information, and it's doubtful you'll need all of it. In this case, we'll only need:

The event key, which tells us what type of event has occurred (e.g., delivered).
The user-variables key, containing user variables we passed to Mailgun. For this example,
we have the email_id key, containing the ID of the row in the email_logs table.

426
The signature key, containing the signature needed to verify the request was actually sent
from Mailgun. We'll cover this later.

Creating the Route

To start building our webhook route, we'll need to create a new route in our Laravel application that
Mailgun can send requests to when an email is delivered. We'll add this to our routes/api.php file:

use App\Http\Controllers\Api\Webhooks\MailgunController;
use Illuminate\Support\Facades\Route;

Route::post(
'/webhooks/mailgun/{status}',
MailgunController::class
)->name('webhooks.mailgun');

As we can see, we've created a new /api/webhooks/mailgun/{status} route for our application.
We will create an EmailStatus enum class containing all the possible values for the {status}
parameter. As a result of using an enum, Laravel will be able to automatically resolve the enum class
when a request is made to the route. This means we can reject requests that don't contain a valid
status value and only accept requests for events we have explicitly defined. We'll cover the enum
class in more depth further on.

It's important to note that the /api prefix for the route was automatically prepended for us because
we added the route to the routes/api.php file. This behaviour is defined in the boot method of the
app/Providers/RouteServiceProvider class that ships with Laravel. The provider may look like
so:

427
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;

class RouteServiceProvider extends ServiceProvider


{
// ...

/**
* Define your route model bindings, pattern filters, and
* other route configuration.
*/
public function boot(): void
{
// ...

$this->routes(function () {
Route::middleware('api')
->prefix('api')
->as('api.')
->group(base_path('routes/api.php'));

// ...
});
}
}

It's worth noting that we have also manually added the as('api.') line. This will automatically
prepend all the route names in our api.php file with api.. Although you'll likely never be interacting
with the API routes in your own application using the route names, you can use this feature for testing
purposes. For example, in our tests, we can call route('api.webhooks.mailgun') rather than
/api/webhooks/mailgun. However, this is purely personal preference and not something you have
to do.

428
Creating the Enum

As mentioned above, we will create an EmailStatus enum that contains all possible values for the
{status} parameter. This will allow us to reject requests that don't contain a valid status and only
accept requests for events that we have explicitly defined.

We can also use the EmailStatus enum in the EmailLog model we'll create next. This will allow us
to improve the accuracy and type safety of our EmailLog model.

Let's create a new app/Enums/EmailStatus.php file that will contain our EmailStatus enum:

namespace App\Enums;

enum EmailStatus: string


{
case Pending = 'pending';

case Delivered = 'delivered';

case TemporaryFail = 'temporary_fail';

case PermanentFail = 'permanent_fail';


}

As a result of creating these enum values, we can now make requests to the following variations of
our /api/webhooks/mailgun/{status} route:

/api/webhooks/mailgun/pending
/api/webhooks/mailgun/delivered
/api/webhooks/mailgun/temporary_fail
/api/webhooks/mailgun/permanent_fail

Typically, you could now enter these URLs in your Mailgun dashboard to configure webhooks to send
to these routes. For example, you could configure a webhook to be sent to
/api/webhooks/mailgun/delivered whenever an email is delivered. Note that we won't set up a
webhook to send to the /api/webhooks/mailgun/pending route because the emails would be
automatically set as pending when first created in the EmailLog model.

429
Creating the Model

We must now create our EmailLog model that can be used to interact with the email_logs table in
the database. For this chapter, assume we have already created the table and added any columns we
want.

To improve the accuracy and type safety of our EmailLog model, we'll use the EmailStatus enum
containing possible values of the status column in the email_logs table.

We can then update our EmailLog model to use the EmailStatus enum cast for the status
column:

declare(strict_types=1);

namespace App\Models;

use App\Enums\EmailStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

final class EmailLog extends Model


{
use HasFactory;

protected $casts = [
'status' => EmailStatus::class
];
}

As a result of this, we can write things like $emailLog->status = EmailStatus::Delivered


and $emailLog->status === EmailStatus::Pending so we can interact with the enum in our
application's code. But Laravel will take care of the casting to store it in the database as a string (such
as delivered).

430
Creating the Controller

We can now create the new MailgunController we referenced in the route. We can do this by
running the following Artisan command in our project's root:

php artisan make:controller Api/Webhooks/MailgunController -i

You may have noticed we used the -i option when running the command. This option creates a new
controller with only a single method, the __invoke method. This is different from the default
behaviour of the make:controller command, which creates a controller with all the CRUD methods
(such as index, create, store, show, edit, update, destroy). I use this approach because I
prefer my webhook controllers to have only one method. This helps ensure a controller is only
responsible for handling a single webhook request. I usually create a new controller for each webhook
I need to handle.

Now that we have the outline of the controller created, we can add the code to handle the webhook
request. We'll keep all the code to handle the webhook inside the controller for brevity. But you may
want to extract the logic from the controller into a service or action class. Your code may look
something like this:

431
declare(strict_types=1);

namespace App\Http\Controllers\Api\Webhooks;

use App\Enums\EmailStatus;
use App\Http\Controllers\Controller;
use App\Models\EmailLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class MailgunController extends Controller


{
public function __invoke(Request $request, EmailStatus $status): JsonResponse
{
// Grab the data we need from the request.
$emailId = $request->input('event-data.user-variables.email_id');

// Update the email log with the new status.


EmailLog::query()
->whereKey($emailId)
->update([
'status' => $status,
]);

// Return a successful response.


return response()->json(['success' => true]);
}
}

In our controller above, we've added the logic to handle the webhook request if a delivered,
temporary_fail, or permanent_fail event is received. As we've already mentioned, if an invalid
status is passed to the controller that cannot be resolved to an EmailStatus enum value, Laravel
will automatically return a 404 response. This gives us some extra peace of mind that we won't be
handling webhook events we don't expect. We then update the email_status field in the database
with the new status. Finally, we return a successful JSON response to the webhook provider (in this
case, Mailgun). This is important as it lets the webhook provider know we've received the webhook

432
and handled it successfully.

The controller in our example above doesn't extensively track the email. For instance, we're not
logging the time the email status was updated or tracking previous statuses. You may want to add
this, but we have kept the code simple to focus on handling webhooks.

Even though we haven't configured any webhooks to be sent to the pending variation of the route,
you may also want to add a check to your controller to reject any requests to
/api/webhooks/mailgun/pending for improved safety.

A handy point to remember is that many third-party services use similar payloads for each webhook
they send. For example, the payload sent by Mailgun for the delivered event has the same
structure as all the other event payloads (such as temporary_fail, permanent_fail, etc.). As
mentioned previously, this means you can sometimes reuse the same controller for all the webhooks
you're receiving from Mailgun if it's written in a way that can handle all the different events (such as
the controller in our example). This isn't the case with every service, so you must check the
documentation for each service to see how they handle their webhooks.

It's important to return a suitable response if an error occurs. Some third-party systems allow you to
return specific error codes instructing them to keep retrying to deliver the webhook. For example,
Mailgun states the following in their documentation:

If Mailgun receives an HTTP 200 (success) response code from your webhook route, it will
determine that the webhook request was successful and not retry.
If Mailgun receives an HTTP 406 (not acceptable) response code from your webhook route, it
will determine that the webhook request was rejected and will not retry the request again.
If Mailgun receives any other status code (e.g. 500, 400, 403, etc.), it will determine that the
webhook request failed and will retry the request again using the following schedule: 10
minutes, 10 minutes, 15 minutes, 30 minutes, 1 hour, 2 hours, and 4 hours.

It is strongly advised to read the documentation for the webhook provider you're using to see how
they handle errors and what response codes they expect you to return. The ability to retry the
webhooks can be useful if your webhook-handling code currently has any errors or bugs, as you may
be able to fix the issue, and the webhook provider will automatically retry the webhook request.

433
Webhook Security
As with any part of your application, it's crucial to handle webhooks securely. This is especially
important when using webhooks to trigger actions like sending emails or notifications. If you have
insufficient security measures, anyone could send a request to your webhook route and trigger the
action.

A typical way to ensure a webhook was sent from the expected third-party service (not a malicious
user) is by verifying a signature or token sent in the payload. Services may implement this in different
ways. For example, some providers pass a signature in the payload and expect you to build a
signature using the same algorithm and compare the two. Others pass a secret token in the payload
to compare to a secret token stored in your application's .env file.

Verifying the requests generally involves having a secret token or key in your application's .env that
no one else should know about apart from the third-party service. Thus, if someone else sent a
request to your webhook route, they wouldn't have the secret token or key, so the request would be
rejected.

Why You Must Secure Your Webhooks

To understand why securing webhook routes is necessary, let's look at an example of how someone
could exploit an unsecured webhook route.

Imagine you're building a software-as-a-service (SaaS) web application that requires payment to
access certain features. Assume you're using Stripe as your payment provider and you've set up a
webhook route that will be triggered whenever a user makes a payment. When the payment is
successful, you want to upgrade your user's account so they can access the premium features. The
workflow may look like this:

1. A user signs up for your application.


2. The user is granted access to the free features.
3. The user upgrades their account to a paid plan.
4. A payment is taken via Stripe.
5. Stripe sends a webhook request to your application to confirm the successful payment.
6. Your application receives the webhook and upgrades the user's account to the paid plan.

Since Stripe is a popular payment provider, it's relatively straightforward to go online and find the
structure of a payload sent in a webhook request. If a malicious user figured out your application's

434
webhook route (perhaps something like /api/webhooks/stripe), they could try sending requests
to the route with a payload.

When building your webhook route and controller, you might assume the data will always be sent
from Stripe. Thus, you may have written the code only to handle a valid payload where the payment
is always successful.

In this case, if a malicious user sends a request to your webhook route with a payload that your
application expects, they could access premium features for free. This is a big security issue you must
take steps to prevent.

One way of doing this is using a middleware to verify the webhook is coming from who you expect.
Usually, this is done by checking the validity of a unique code sent by the third-party service. This
code — a signature, key, or token — should be a secret only your app or the third-party service knows
or can create.

We will review code examples validating that the request is from a third-party service.

Validating a Mailgun Webhook

Let's see how to validate that a webhook request was sent from Mailgun. To verify that a webhook
request is valid, Mailgun provides a signature field in the webhook request body. This signature
field looks like so:

{
"signature": {
"token": "92e79f1325dc7f63b123454765e893937932f9f12345a35b2e",
"timestamp": "1679932336",
"signature": "541346f212345ab19b21e6ba7719be29c95bf9512345643e3b139934581fa1d0"
}
}

The following instructions are provided in the Mailgun documentation to determine whether a
webhook request is valid:

Concatenate the timestamp and token values from the outer signature field together.
Hash the concatenated string using the HMAC SHA256 algorithm and your Mailgun "webhook

435
signing key" as the key. You can find this in your Mailgun dashboard. It'll be stored in your .env
file like so: MAILGUN_SECRET=your-mailgun-webhook-signing-key. This allows us to
access it in our application using config('services.mailgun.secret').
Compare the generated hash with the signature value from the signature field in the
webhook request body.
If the two hashes match, the webhook request is valid.
If the two hashes don't match, the webhook request is invalid and wasn't sent from Mailgun.

Mailgun also advises you to check the timestamp value from the signature field to ensure the
request isn't too old. If the request is older than an amount of time (such as 1 minute), it is likely
invalid. This could be a "replay attack" where a malicious actor attempts to send a request to your
webhook route using a previously sent payload. If you'd prefer not to check against an expiry time,
Mailgun suggests storing the token value in the signature field in your cache. As the token is
unique to each request, you can check if it has been used by storing it in your cache. If it has, then
the request is invalid and should be rejected.

For this guide, we will check the timestamp to ensure that the request isn't older than 1 minute. If it
is, we'll reject the request.

You can change this threshold depending on the type of application you're building. Reducing the
threshold to a lower time can improve the security of the webhook route by reducing the timeframe in
which a request could be captured and replayed in an attack. However, making the threshold too
small could cause valid requests to be rejected, so it's important to balance security and usability. To
help you decide on a suitable time threshold, consider the following:

Network latency - If your Laravel app experiences high latency, the requests can take longer
to send. You may want to increase the threshold to account for this.
Risk tolerance - Consider the severity of the consequences if a malicious user could exploit
your webhook route. If there is potential for harm, you may want to reduce the threshold to
reduce the risk of this happening.

It's also important to ensure your server's clock is correct. If not, the request could be rejected even if
it's valid because the timestamp in the signature field will be too different from the timestamp on
your server. A computer's clock can "drift" over time, as they usually don't run at precisely the correct
speed. Over time, this causes the system time to become less accurate. This may go unnoticed until
time-based activities (such as checking the signature of an incoming webhook request) fail. To keep
your server's clock in sync, you can use NTP (network time protocol) to sync it with an external
authoritative system's time. We won't be covering how to keep your server's clock in sync in this
guide, but it's an essential part of configuring a robust server.

436
To start with verifying that the request has been sent from Mailgun, create a new middleware using
the following command:

php artisan make:middleware VerifyMailgunWebhook

You should now have a new app/Http/Middleware/VerifyMailgunWebhook.php file. Let's


apply this middleware to our MailgunController route that will handle the webhook requests. We
can do this by using the middleware method like so:

use App\Http\Controllers\Api\Webhooks\MailgunController;
use App\Http\Middleware\VerifyMailgunWebhook;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/mailgun', MailgunController::class)
->name('webhooks.mailgun')
->middleware(VerifyMailgunWebhook::class);

We can now update our VerifyMailgunWebhook class. It may look like so:

declare(strict_types=1);

namespace App\Http\Middleware;

use Carbon\Carbon;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

final readonly class VerifyMailgunWebhook


{
public function handle(Request $request, Closure $next): Response
{
if (!$this->verify($request)) {
abort(Response::HTTP_FORBIDDEN, 'Invalid signature.');

437
}

return $next($request);
}

private function verify(Request $request): bool


{
return $this->signatureIsValid($request)
&& $this->timestampIsRecent($request);
}

private function signatureIsValid(Request $request): bool


{
$expectedSignature = $request->input('signature.signature');

return $this->buildSignature($request) === $expectedSignature;


}

private function buildSignature(Request $request): string


{
$token = $request->input('signature.token');
$timestamp = $request->input('signature.timestamp');

return hash_hmac(
algo: 'sha256',
data: $timestamp.$token,
key: config('services.mailgun.secret'),
);
}

/**
* Ensure the timestamp is within 1 minute of the current time.
*/
private function timestampIsRecent(Request $request): bool
{
$timestamp = $request->input('signature.timestamp');

return now()->isBefore(

438
Carbon::createFromTimestamp($timestamp)->addMinute()
);
}
}

Our VerifyMailgunWebhook middleware class is now responsible for verifying that the request is
from Mailgun and was sent less than a minute ago. If the request is invalid, we'll return a 403
(forbidden) response. Otherwise, we'll continue with the request as normal. We can now safely
assume that if the request makes it to our MailgunController route, it's a valid request from
Mailgun.

439
This is a sample from "Consuming APIs in Laravel" by Ash Allen.

For more information, Click here.

You might also like