---
title: "Verifying a delivery"
description: "`OpenEmail::verifyWebhookSignature`: constant-time, with a replay window, and the parsed event back."
url: "https://openemail.uk/docs/php/webhooks/verify"
area: "PHP"
category: "Webhooks"
---

# Verifying a delivery

`OpenEmail::verifyWebhookSignature`: constant-time, with a replay window, and the parsed event back.

## In a request handler

A webhook URL is public. Anything on the internet can POST the right-shaped JSON at it, so a handler that reads the event’s `type` without checking the signature is an open write API.

**webhook.php**

```
use OpenEmail\Exception\WebhookSignatureException;
use OpenEmail\OpenEmail;

$payload = (string) file_get_contents('php://input');

try {
    $event = OpenEmail::verifyWebhookSignature(
        $payload,
        $_SERVER,
        (string) getenv('OPENEMAIL_WEBHOOK_SECRET'),
        toleranceSeconds: 300,
    );
} catch (WebhookSignatureException) {
    http_response_code(400);

    return;
}

error_log($event['type'] . ' ' . $event['id']);
http_response_code(204);
```

Pass the RAW body, as a string. Decoding it and encoding it again changes key order and whitespace, and the signature will not match, which is why the payload is typed `string`: an array, such as the result of `json_decode()` or a framework’s parsed input, is a `TypeError` instead of being checked. The headers can be an array with the header name in any case, as `getallheaders()` returns, `$_SERVER`, where the header arrives as `HTTP_X_OPENEMAIL_SIGNATURE`, any iterable such as a Symfony or Laravel `HeaderBag`, or a PSR-7 request. A value that is an array is read from its first element.

Two things this handles that a hand-rolled check usually does not: it compares the MAC in constant time with `hash_equals()`, so the correct prefix cannot be recovered by timing it, and it rejects a delivery more than `toleranceSeconds:` old in either direction, five minutes unless you say otherwise, so a captured request is not replayable for ever. `toleranceSeconds: 0` turns the replay check off. Both bugs are silent. A handler with either one passes every test you would think to write.

> It throws `OpenEmail\Exception\WebhookSignatureException` on every failure: a missing `X-OpenEmail-Signature` header, one not in the form `t=<seconds>,v1=<hex>`, a timestamp outside the window, a signature that does not match, or a signed body that is not a JSON object. On success it returns the body decoded into an array keyed in camelCase, with `id`, `type`, `createdAt` and `data`, so there is no second `json_decode()` to get wrong.

> An empty secret throws `OpenEmail\Exception\InvalidArgumentException` instead, because that is a mistake in your configuration rather than a bad delivery, and the `(string)` in front of `getenv()` turns an unset variable into exactly that. Catch only `WebhookSignatureException` and answer 400. A missing secret then ends the script with an uncaught exception, which PHP in production answers with a 500, and the delivery is tried again once you fix it, rather than every event being turned away as forged.

> The check runs on `hash_hmac()` and `hash_equals()`, which every PHP build has, so it needs no extension. The sample `return`s rather than calling `exit`, so the same lines also work inside a function or a front controller.

## In a PSR-7 app

With a PSR-7 request, as Slim and Mezzio hand you, pass the request itself as the headers and its body as a string.

**webhook_handler.php**

```
use OpenEmail\Exception\WebhookSignatureException;
use OpenEmail\OpenEmail;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

function receiveOpenEmail(ServerRequestInterface $request, ResponseFactoryInterface $responses): ResponseInterface
{
    try {
        $event = OpenEmail::verifyWebhookSignature(
            (string) $request->getBody(),
            $request,
            (string) getenv('OPENEMAIL_WEBHOOK_SECRET'),
        );
    } catch (WebhookSignatureException) {
        return $responses->createResponse(400);
    }

    error_log($event['type'] . ' ' . $event['id']);

    return $responses->createResponse(204);
}
```

Answer quickly: a delivery that gets no answer within 5 seconds counts as failed and is sent again later, so hand the work to a queue and reply with a 2xx. The Laravel and Symfony controllers, with the hand-off to the queue, are on the frameworks page.

- [Frameworks](https://openemail.uk/docs/php/frameworks.md): Receive webhooks in a Laravel or Symfony controller.

> `OpenEmail\Constants\WebhookSignatureHeaders` names the three headers a delivery carries: `X-OpenEmail-Signature`, `X-OpenEmail-Event` with the event’s type, and `X-OpenEmail-Delivery` with its id, the same `id` as in the body. That id stays the same on every retry and replay of the event, so it is the one to store when you skip events you have already handled.

## While the secret rotates

`webhooks->rotateSecret` has no overlap window: deliveries are signed with the new secret from the moment it returns. Deploy a receiver that accepts either secret first, rotate, store the new secret where the receiver reads it, then drop the old one.

**two_secrets.php**

```
use OpenEmail\Exception\WebhookSignatureException;
use OpenEmail\OpenEmail;

function verifyDelivery(string $payload, mixed $headers): array
{
    $next = getenv('OPENEMAIL_WEBHOOK_SECRET_NEXT');

    try {
        return OpenEmail::verifyWebhookSignature($payload, $headers, (string) getenv('OPENEMAIL_WEBHOOK_SECRET'));
    } catch (WebhookSignatureException $error) {
        if (!is_string($next) || $next === '') {
            throw $error;
        }

        return OpenEmail::verifyWebhookSignature($payload, $headers, $next);
    }
}
```

`webhooks->test` sends a signed synthetic event, so call it after the rotation to prove the new secret verifies before you remove the old one.
