Use SendGrid Event Webhooks in PHP

May 09, 2023
Written by
Reviewed by

Use SendGrid Event Webhooks in PHP

Modern email is not your grandfather's email! Back then, you sent an email using apps such as Pine, Outlook, Eudora or Thunderbird. The recipient opened and read your email and responded to you (hopefully). Now, you have to manage mailing lists, handle unsubscribes, deal with spam reports, and so much more!

But how can you do that quickly, efficiently, and programmatically using PHP? Enter SendGrid's Event Webhooks. If you've not heard of them before Event Webhooks:

> Will notify a URL of your choice via HTTP POST with information about events that occur as SendGrid processes your email.

These events include:

  • Emails being delivered to the receiving server, bounced, dropped, and opened;
  • When people click on links in your emails;
  • When people unsubscribe

Your applications can react to these events as they're received, in ways that make sense for your organisation. This saves staff the need to do so manually.

In this tutorial, you're going to learn how to do so by logging when users unsubscribe, click on a link, and mark an email as spam.

Prerequisites

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

How the application works

There's not that much to the application, as it only consists of two routes, the default route, and a route to send emails.

The default route receives signed event webhook requests from SendGrid. These requests contain three key things: a JSON array of event webhook data (which you can see an example of below) in the request's body, and two headers (X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp).


  {
        "email": "example@test.com"

Using the webhook's public key, which will be generated later, and the X-Twilio-Email-Event-Webhook-Timestamp header, which contains the time when the data was signed, the application will verify the signature's webhook contained in X-Twilio-Email-Event-Webhook-Signature.

If the signature is valid, then it will log if any of the received events were a click, spam report, or an unsubscribe. Otherwise, it will print that the data did not validate successfully.

The second route is only there to simplify sending test emails (/email) which in turn trigger sending event webhook data.

Scaffold the base application

The first thing to do is to scaffold the application and change into the newly created project directory. We'll save some time by using the Mezzio Skeleton project to do this. Run the following commands wherever you store your PHP projects.

composer create-project mezzio/mezzio-skeleton sendgrid-webhook-receiver
cd sendgrid-webhook-receiver

When prompted, answer the questions as follows:

What type of installation would you like? 3 (Modular)
Which container do you want to use for dependency injection? 2 (laminas-servicemanager)
Which router do you want to use? 1 (FastRoute)
Which template engine do you want to use? 2 (Twig)
Which error handler do you want to use during development? 1 (Whoops)
Please select which config file you wish to inject 'Laminas\HttpHandlerRunner\ConfigProvider' into: 1
Remember this option for other packages of the same type? (Y/n) Y

If you see the following at the end of the script output, run composer update to correct the issue.


- Required package "laminas/laminas-servicemanager" is not present in the lock file.
- Required package "mezzio/mezzio-fastroute" is not present in the lock file.
- Required package "mezzio/mezzio-twigrenderer" is not present in the lock file.

This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.
Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md
and prefer using the "require" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r

Then, create a directory for the webhook log file, by running the following command.

mkdir data/log

Haven't heard of Mezzio before?

It's the successor to Zend Framework and Zend Expressive. It's a framework suited to building any type of application, from a tiny microservice to an enterprise-ready legacy application. It comes with a large ecosystem of packages and command-line tooling to take the pain out of numerous development tasks.

Grab a copy of my book Mezzio Essentials, if you're keen to learn more about it or check out the excellent documentation.

Install the required packages

Back to the tutorial. Next, you need to add several packages which the project needs. These are:

PackageDescription
MonologPHP's de facto logging package will be used to log webhook information.
laminas-sendgrid-integrationThis package makes integrating the Official Twilio SendGrid PHP API Library in Mezzio projects virtually a breeze. All you need to do is set a single environment variable. The package registers a service named `SendGrid` with the application's DI container, which is a `SendGrid` object initialised with your SendGrid API key.
PHP DotenvThis package simplifies setting environment variables from dotenv files.

To install them, run the following command.

composer require \
    seldaek/monolog \
    settermjd/laminas-sendgrid-integration \
    vlucas/phpdotenv

Replace the backslash with a caret (^) if you're using Microsoft Windows.

Set the required environment variables

Next, you need to set four environment variables. To do that, first create a new file named .env in the top-level directory of the project. In that file, add the code below.

SENDGRID_API_KEY=<<SENDGRID_API_KEY>>
SENDGRID_SENDER_ADDRESS=<<SENDGRID_SENDER_ADDRESS>>
SENDGRID_RECIPIENT_ADDRESS=<<SENDGRID_RECIPIENT_ADDRESS>>
SENDGRID_WEBHOOK_PUBLIC_KEY=<<SENDGRID_WEBHOOK_PUBLIC_KEY>>

The first variable is your SendGrid API key. If you don't already have one, log in to your SendGrid account and under Settings > API Keys click Create API Key.

Create a SendGrid API key

In the form that appears, enter a meaningful name for the key in the API Key Name field and click Create & View.

SendGrid API key created.

The SendGrid API key will now be visible. Copy and paste it into .env in place of the <<SENDGRID_API_KEY>> placeholder.

Be aware that you will only be able to view the API key once. If you navigate away from the page or close the tab/window and haven't copied the API key, you will have to create a new one.

List of single sender verified email addresses.

Then, back in the left-hand side navigation menu of the SendGrid Dashboard, click Settings > Sender Authentication. There, copy one of the email addresses from the Single Sender Verification section, and paste it into .env in place of the <<SENDGRID_SENDER_ADDRESS>> placeholder. Then, add a recipient email address in place of the <<SENDGRID_RECIPIENT_ADDRESS>>.

Start ngrok

Next, start ngrok so that you have a public-facing URL to which the event webhook can post, by running the command below.

ngrok http 8080

You should see output similar to the following.


ngrok                                                                                                             (Ctrl+C to quit)
                                                                                                                                  
Announcing ngrok-rs: The ngrok agent as a Rust crate: https://ngrok.com/rust                                                      
                                                                                                                                  
Session Status                    online                                                                                              
Account                           Matthew Sette (Plan: Free)                                                                          
Version                           3.2.2                                                                                               
Region                            Europe (eu)                                                                                         
Latency                           -                                                                                                   
Web Interface                     http://127.0.0.1:4040                                                                               
Forwarding                        https://d06f-2001-9e8-33c3-db00-8824-ed66-b57d-18c7.ngrok-free.app -> http://localhost:8080         
                                                                                                                                  
Connections                       ttl         opn         rt1         rt5         p50         p90                                                         
                                  0           0           0.00        0.00        0.00        0.00

Make a copy of the Forwarding URL, as you'll need it next to create an event webhook.

Create an Event Webhook

Create a SendGrid Event Webhook

 

To create an Event Webhook, go back to the SendGrid Dashboard and navigate to Settings > Mail Settings. Then, under Webhook Settings, click Event Webhooks. From there, click Create new webhook on the right-hand side of the page.

In the dialog that opens:

  • Enter a name for the webhook;
  • Check all of the options under Actions to be posted;
  • Under Signature Verification, enable Enable Signed Event Webhook; and
  • Set the ngrok Forwarding URL you copied previously as the value of Post URL
  • Click Save

During creation of the Webhook, a private key for signing webhook data and a public key for verifying it were created. To retrieve the public key, click the cog icon on the far right-hand side of the webhook to reopen the webhook settings. Then, under verification key, copy the webhook's public key and paste it in place of <<SENDGRID_WEBHOOK_PUBLIC_KEY>> in the .env file.

Create the route handlers

Next, create the handlers for the two routes, by running the following commands.

composer laminas mezzio:handler:create \
    "App\Handler\SendGridWebhookHandler" \  
    --without-template \
    --no-factory

composer laminas mezzio:handler:create \
    "App\Handler\SendEmailHandler" \ 
    --without-template \
    --no-factory

These commands will create two classes (PSR-15: server request handlers) in src/App/src/; one to receive and process event webhooks: SendGridWebhookHandler.php, and the other to send test emails: SendEmailHandler.php.

Update the event webhook handler

Now, it's time to flesh out the webhook handler. To do that, update src/App/src/SendGridWebhookHandler.php to match the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use App\Filter\EventTypeFilterIterator;
use ArrayIterator;
use Laminas\Diactoros\Response\{EmptyResponse,TextResponse};
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Http\Message\ServerRequestInterface;
use SendGrid\EventWebhook\{EventWebhook,EventWebhookHeader};

use function json_decode;
use function sprintf;

use const JSON_OBJECT_AS_ARRAY;

class SendGridWebhookHandler implements RequestHandlerInterface
{
    private Logger $logger;

    public function __construct() 
    {
        $this->logger = new Logger('webhook-logger');
        $this->logger->pushHandler(
            new StreamHandler(__DIR__ . '/../../../../data/log/webhook.log')
        );
    }

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        if (! $request->hasHeader(EventWebhookHeader::SIGNATURE)) {
            $message = sprintf(
                "Request did not include the %s header.", 
                EventWebhookHeader::SIGNATURE
            );
            $this->logger->error($message, $request->getHeaders());
            return new TextResponse($message, 400);
        }

        if (! $request->hasHeader(EventWebhookHeader::TIMESTAMP)) {
            $message = sprintf(
                "Request did not include the %s header.", 
                EventWebhookHeader::TIMESTAMP
            );
            $this->logger->error($message, $request->getHeaders());
            return new TextResponse($message, 400);
        }

        $requestBody = $request->getBody()->getContents();
        $signature   = $request->getHeaderLine(EventWebhookHeader::SIGNATURE);
        $timestamp   = $request->getHeaderLine(EventWebhookHeader::TIMESTAMP);

        $eventWebHook  = new EventWebhook();
        $verifiedSignature = $eventWebHook->verifySignature(
        $eventWebHook->convertPublicKeyToECDSA(
            $_SERVER['SENDGRID_WEBHOOK_PUBLIC_KEY']),
            $requestBody,
            $signature,
            $timestamp
        );

        if (! $verifiedSignature) {
            $message = 'SendGrid webhook data did not successfully validate.';
            $this->logger->error($message);
            return new TextResponse($message, 400);
        }

        $events = new EventTypeFilterIterator(
            new ArrayIterator(
                json_decode(
                    $requestBody,
                    associative: true,
                    flags: JSON_OBJECT_AS_ARRAY
                )
            )
        );
        foreach ($events as $event) {
            $this->logger->debug("click, spam, or unsubscribe event received.", $event);
        }

        return new EmptyResponse(200);
    }
}

In the handle method, the code first checks if both of the required headers were sent. If not, it logs which one wasn't and prints that to the application's output. Otherwise, the request's body and the two headers are retrieved. Then, using the generated public key, request body, and timestamp, it verifies if the received signature is valid or not.

If the received signature is invalid, that is logged to the log file and printed as the application's output. Otherwise, it uses the EventTypeFilterIterator, created next, to filter the received events to only events that were a click (click), spam report (spamreport), or an unsubscribe (unsubscribe), and logs receipt of each one.

Create an Event Webhook filter

To create the filter, in src/App/src/ create a new directory named Filter. Then, in the Filter directory, create a new file named EventTypeFilterIterator.php. In that file, paste the following code.

<?php

declare(strict_types=1);

namespace App\Filter;

use FilterIterator;
use Iterator;

use function in_array;

class EventTypeFilterIterator extends FilterIterator
{
    private array $allowedEvents = ['click', 'spamreport', 'unsubscribe'];

    public function __construct(Iterator $iterator)
    {
        parent::__construct($iterator);
    }

    public function accept(): bool
    {
        /** @var array $event */
        $event = $this->getInnerIterator()->current();
        return in_array($event['event'], $this->allowedEvents);
    }
}

There's not a lot to the class. It extends FilterIterator in PHP's SPL. It does this because FilterIterator simplifies filtering out unwanted values from a set of data through the logic in the accept() method. It does this by returning true if the event's event property is set to click, spamreport, or unsubscribe or false otherwise.

Update the send email handler

Next, update the handler that sends test emails by updating src/App/src/SendGridWebhookHandler.php to match the code below.

<?php

declare(strict_types=1);

namespace App\Handler;

use Exception;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\Response\TextResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use SendGrid;
use SendGrid\Mail\Mail;

readonly class SendEmailHandler implements RequestHandlerInterface
{
    public function __construct(private SendGrid $sendGrid) {}

    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $email = new Mail();
        $email->setFrom(
            $_SERVER['SENDGRID_SENDER_ADDRESS']
        );
        $email->setSubject("Sending with Twilio SendGrid is Fun");
        $email->addTo(
            $_SERVER['SENDGRID_RECIPIENT_ADDRESS']
        );
        $email->addContent("text/plain", "and easy to do anywhere, even with PHP");
        $email->addContent(
            "text/html",
            "<strong>and easy to do anywhere, even with PHP</strong>"
        );

        try {
            $response = $this->sendGrid->send($email);
            return new JsonResponse(
                [
                    'status' => $response->statusCode(),
                    'headers' => $response->headers(),
                    'Body' => $response->body(),
                ]
            );
        } catch (Exception $e) {
            return new TextResponse('Caught exception: ' . $e->getMessage() . "\n");
        }
    }
}

This class first creates and populates a SendGrid Mail object, which models an email. The email will be sent to the email address set in SENDGRID_RECIPIENT_ADDRESS, from the email address set in SENDGRID_SENDER_ADDRESS. It will contain a short message: "Sending with Twilio SendGrid is Fun and easy to do anywhere, even with PHP".

Then, the email is attempted to be sent via SendGrid's API. If successful, a JSON string will be returned containing the status code, headers, and body of the response received from the request to SendGrid. If there was an exception, it is printed instead.

Update the application's configuration

Now, it's time to update the application's configuration by registering services in the DI container for the two handlers. To do this, update the getDependencies() method in src/App/src/ConfigProvider.php to match the following.

hl_lines="4,5,6,7"
public function getDependencies(): array
{
    return [
        'factories'  => [
            Handler\SendEmailHandler::class => ReflectionBasedAbstractFactory::class,
            Handler\SendGridWebhookHandler::class => InvokableFactory::class,
        ],
    ];
}

Then, add the following use statement at the top of the file.

use Laminas\ServiceManager\AbstractFactory\ReflectionBasedAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;

The services are supplied or instantiated, by a combination of two factories: the Reflection-based abstract factory and the InvokableFactory.

The Reflection-based abstract factory is one of the classes I use most when developing Mezzio applications because it makes instantiation pretty trivial. It's able to do this because it uses PHP’s Reflection API to introspect a class' constructor parameters and, where possible, retrieve them from the DI container. By using it we don't need to create a separate factory class to do so.

InvokableFactoryinstantiates classes with no dependencies or which accept a single array. This makes it the right choice for instantiating SendGridWebhookHandler and removes any associated Reflection overhead.

Update the routing table

Now, there's one more step to go before testing the application: adding the required routes to the routing table. To do that, update config/routes.php to match the following.

hl_lines="8,9,10,11,12,13,14,15,16,17"
<?php

return static function (
    Application $app, 
    MiddlewareFactory $factory, 
    ContainerInterface $container
): void {
    $app->route(
        '/',
        [
            \Mezzio\Helper\BodyParams\BodyParamsMiddleware::class,
            App\Handler\SendGridWebhookHandler::class
        ],
        ['post', 'get'],
        'sendgrid.webhook'
    );
    $app->get('/email', App\Handler\SendEmailHandler::class, 'sendgrid.email');
    $app->get('/api/ping', App\Handler\PingHandler::class, 'api.ping');
};

Test the application

With the application complete, it's time to test it. First, start it by running the following command.

composer serve

This will start the application listening on localhost, on port 8080. Now, replace https://<<the ngrok Forwarding URL>> in the command below, with your ngrok Forwarding URL and then run it.

curl -v https://<<the ngrok Forwarding URL>>/email

You should see the HTTP status code, headers, and body of the request to SendGrid's API to send the email. A short while later, you should see an email arrive in your inbox. Now, open data/log/webhook.log, where you should see output similar to the example below.

2023-05-05T09:29:54.698915+00:00] webhook-logger.DEBUG: click

That's how use SendGrid Event Webhooks in PHP

You now know how to use them to respond to them programmatically in PHP. I'm sure that your code would respond to event webhooks more imaginatively than the code in this tutorial did. However, it still showed how to retrieve them securely.

What ways would you respond to them? Please let me know on Twitter.

P.S., check out this tutorial if you want to learn the essentials of sending emails with PHP.

Matthew Setter is a PHP Editor in the Twilio Voices team and a PHP, Go, and Rust developer. He’s also the author of Mezzio Essentials and Deploy With Docker Compose. When he's not writing PHP code, he's editing great PHP articles here at Twilio. You can find him at msetter[at]twilio.com, on LinkedIn, Twitter, and GitHub.

Email icon in the main image available via Smashicons - Flaticon