How to Validate Twilio Event Streams Webhooks in PHP

September 09, 2024
Written by
Reviewed by

How to Validate Twilio Event Streams Webhooks in PHP

One of Twilio's stand out features is its Event Streams Webhooks. These are HTTP requests where the body of each webhook is a JSON array of CloudEvents.

Without going into too much detail, webhooks let your application know when specific events happen, such as receiving an SMS message or receiving an incoming phone call, and respond to them accordingly.

Requests include details of the event, such as the body of an incoming message, the phone or WhatsApp number it was sent from, and the event's name. With that information, your application can become extremely flexible and powerful.

However, like all application input — regardless of its source — you can't blindly trust it. You must validate it! The same goes for Event Streams Webhooks.

Gladly, Twilio makes this pretty trivial. Each webhook request is signed, contained in the X-Twilio-Signature header. The signature, in combination with your account's Auth Token, the body of the webhook, and the request URL, are used to verify that the webhook came from Twilio.

In this short tutorial, you're going to learn how to do just that. Let's begin.

Prerequisites

To follow along with this tutorial, you'll need the following:

  • PHP 8.3
  • Composer installed globally
  • ngrok and an an ngrok account (whether free or paid)
  • A Twilio account (either free or paid). If you are new to Twilio, click here to create a free account
  • Your preferred text editor or IDE, and your preferred web browser

How the app will work

The app is pretty small, having only one route (/webhook), where it will receive webhook requests from Twilio. When requests are received the app will attempt to validate them. If the request is valid, it will write that to the app's log file, along with the event details and request headers. If it's invalid, then "Invalid signature" will be written to the log file.

Create the core of the project

Okay, as (almost) always in my tutorials, we're going to start off by creating the project's core file and directory structure. To do so, run the command below.

mkdir -p validate-webhook-signature/public

If you're using Microsoft Windows, don't worry about the -p option, as it's not required.

Add the required packages

The next thing to do is to install the required packages, those being:

  • The Slim framework: We'll be using Slim as it's one of PHP's lightest and most unobtrusive frameworks
  • Slim's PSR-7: This is a PSR-7 implementation for the Slim framework which makes it super simple to work with HTTP requests and responses
  • PHP-DI's Slim Bridge: This package simplifies integrating PHP-DI as Slim's DI container
  • Twilio's PHP Helper Library: This package simplifies interacting with Twilio's APIs in PHP
  • PHP Dotenv: This package loads environment variables into PHP's $_SERVER and $_ENV superglobals, making it trivial to configure apps during local development

To install them, run the following command.

composer require php-di/slim-bridge slim/psr7 slim/slim twilio/sdk vlucas/phpdotenv

Add the ability to validate a webhook

Now, it's time to add the PHP validation code. Start by creating a file named index.php in the public directory. Then, in that file, add the following code.

<?php

declare(strict_types=1);

use DI\Container;
use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\Factory\AppFactory;
use Twilio\Rest\Events\V1\SinkInstance;
use Twilio\Rest\Events\V1\SubscriptionInstance;
use Twilio\Security\RequestValidator;

require __DIR__ . '/../vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . "/../");
$dotenv->load();
$dotenv
    ->required(['NGROK_URL', 'TWILIO_AUTH_TOKEN'])
    ->notEmpty()

$container->set(
    LoggerInterface::class,
    fn () => (new Logger('name'))
        ->pushHandler(
            new StreamHandler(
                __DIR__ . "/../data/logs/app.log",
                Level::Debug
            )
        )
);

AppFactory::setContainer($container);
$app = AppFactory::create();

$app->addBodyParsingMiddleware();

$app->post(
    '/webhook', 
    function (
        ServerRequestInterface $request, 
        ResponseInterface $response, array $args
    ) {
        $validator = new RequestValidator($_ENV["TWILIO_AUTH_TOKEN"]);
        $isFromTwilio = $validator->validate(
            $request->getHeaderLine('X-Twilio-Signature'),
            $_ENV['NGROK_URL'] . '/webhook?' . http_build_query($request->getQueryParams()),
            (string)$request->getBody()
        );

        /** @var LoggerInterface $logger */
        $logger = $this->get(LoggerInterface::class);

        $body = ($isFromTwilio) ? "Valid signature." : "Invalid signature.";
        ($isFromTwilio)
            ? $logger->info("Valid signature. Processing event.")
            : $logger->info(
            "Invalid signature.",
            [
                'Twilio Signature' => $request->getHeaderLine('X-Twilio-Signature'),
                'Request URI' => $_ENV['NGROK_URL'] . '/webhook?' . http_build_query($request->getQueryParams()),
                'Event Data' => (string)$request->getBody(),
            ]
        );

        $logger->info(
            "Event received",
            $request->getParsedBody(),
        );

        $logger->info(
            "Request headers",
            [
                $request->getHeaders(),
            ]
        );

        return $response;

});

$app->run();

The code starts by using PHP Dotenv to load the variables defined in .env, which we'll define shortly, into PHP's $_SERVER and $_ENV superglobals. It will throw an exception if either 'NGROK_URL' or TWILIO_AUTH_TOKEN are not defined or are empty.

It then defines a small Slim app with a single service in its DI container (LoggerInterface) to provide application output. A single route (/webhook) accessible via HTTP POST, for receiving and validating event stream webhooks.

The route validates the webhook with three components:

  • The X-Twilio-Signature header
  • The request URL, built from a combination of the base URL, defined in NGROK_URL (which will be set shortly), the route's path (/webhook), and the request's query string
  • The raw request body

If the request is valid, it writes "Valid signature. Processing event." to the log file, along with the request's body and headers, and "Valid signature." as the response's body.

Otherwise, it writes "Invalid signature." to the log file along with the contents of the X-Twilio-Signature header, the compiled request URI, event data and request headers to the log file, and "Invalid signature." as the response's body.

Configure the required environment variables

With the code written, you next need to set a handful of environment variables which the app requires; these are NGROK_URL and TWILIO_AUTH_TOKEN, which are required to validate the webhook.

First, create a new file in the project's top-level directory named .env. Then, in that file paste the configuration below.

NGROK_URL=<NGROK_URL>
TWILIO_AUTH_TOKEN=<TWILIO_AUTH_TOKEN>
Screenshot

Next, in the Account Dashboard of your Twilio Console, copy your Auth Token and paste it into .env in place of <TWILIO_AUTH_TOKEN>.

Make the application publicly accessible and retrieve its public URL

You next need to expose the application to the public internet, so that Twilio can send webhook requests to it. To do that, we're going to use ngrok, by running the command below.

Run the command below to create a secure tunnel to port 8080; the application doesn't need to be running for this to work.

ngrok http 8080
If you're not familiar with ngrok, it's a quick and easy way to create secure tunnels to applications running in local development environments.

In the terminal output, you'll see a Forwarding URL. Copy that and paste it into .env in place of <NGROK_URL>.

Create a Sink Resource

The next step is to create a Sink resource. Technically, these are:

the destinations to which events selected in a subscription will be delivered

For the purposes of this application, they're a quick and easy way to simulate sending webhook requests to our application so that we can validate them.

To create one, in the Twilio Console, navigate to Explore Products > Developer Tools (right near the bottom of the page) > Event Streams. There, click "Create new sink" to start the process.

Then, set Sink description to "Validate Webhook Sink", set sink type to "Webhook", and click "Next step".

After that, set Destination to the Forwarding URL printed by ngrok to the terminal, with "/webhook" at the end, leave Method set to "POST", and click Finish. Then, in the confirmation popup that appears, click "View Sink Details", taking you to the sink's properties page.

Test that the application works

Right, let's check that the code works as expected. To do that, first start the application listening on port 8080 by running the following command in your terminal.

composer serve

Now, back in the Twilio Console, on the Sink resource's properties page, click "Send test event". Back in your terminal, you should see output similar to the following printed to the terminal if the webhook validated successfully:

Subscribe to incoming messages

Finally, let's subscribe to an event so that you can see the application responding as it normally would in production. To do that, back in the Twilio Console, under Explore Products > Developers > Event Streams, click Create in the top right corner, then click New subscription.

Then, in the Create new subscription form, select the "Validate Webhook Sink" from the Select sink dropdown, and add a description for the subscription, in the Subscription description field, as in the screenshot below.

Then, scroll down to the Product groups section, click Messaging in the left-hand navigation menu, and under Messaging, scroll down to Inbound Message. There, under Action, you'll see Received. On its far right-hand side, set the dropdown to "1". Then, click Create Subscription in the bottom right-hand corner of the page.

Test the subscription

Now, let's test that the subscription works. Back in the Twilio Console, navigate to Explore Products > Messaging > Try it out > Send a Whatsapp message. There, first, follow the instructions to connect to the WhatsApp Sandbox.

After you've done that, with WhatsApp, send a message to your Twilio number. In your log file, you should see three log entries. The first should be "Valid signature. Processing event.". The second should be "Event received" along with the event's details (an example of which you can see below, formatted for readability). The third should be "Request headers", along with a list of the request's headers.

{
    "specversion": "1.0",
    "type": "com.twilio.messaging.inbound-message.received",
    "source": "/2010-04-01/Accounts/AC98e9ac842e79bd12c9da461bcc65b05d/Messages/SMc58b54fb352595f44b182f8e4822a661.json",
    "id": "EZcd9aec676b67769ad88ba152d7dd8c64",
    "dataschema": "https://events-schemas.twilio.com/Messaging.InboundMessageV1/1",
    "datacontenttype": "application/json",
    "time": "2024-09-02T01:24:20.000Z",
    "data": {
        "numMedia": 0,
        "timestamp": "2024-09-02T01:24:20.000Z",
        "accountSid": "AC98e9ac842e79bd12c9da461bcc65b05d",
        "to": "whatsapp:+14155558888",
        "numSegments": 1,
        "messageSid": "SMc58b54fb352595f44b182f8e4822a661",
        "eventName": "com.twilio.messaging.inbound-message.received",
        "body": "An awesome message!!",
        "from": "whatsapp:+61000111222"
    }
}

That's how to how to validate Twilio webhooks in PHP

There's a (little) bit to do to get it all set up and running. However, after it's done, you can now validate that the webhook data you receive truly is from Twilio. Remember, no input should ever be trusted unconditionally.

Want to validate Event Streams Webhooks even easier?

If you want to validate Event Streams Webhooks even easier than in the code you've just seen, I'm creating integrations for some of PHP's most popular frameworks.

Find yours in the list below (which I'll update as more are released):

Matthew Setter is a PHP and Go editor in the Twilio Voices team and a PHP and Go developer. He’s also the author of Mezzio Essentials and Deploy With Docker Compose. You can find him at msetter[at]twilio.com and on LinkedIn and GitHub.