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.
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 returns 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.
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.
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.
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.