Verificar uma entrega
`OpenEmail::verifyWebhookSignature`: em tempo constante, com uma janela de repetição, e devolve o evento já analisado.
Num handler de pedidos
Um URL de webhook é público. Qualquer coisa na internet lhe pode enviar por POST um JSON com a forma certa, por isso um handler que lê o type do evento sem verificar a assinatura é uma API de escrita aberta.
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);Passe o corpo EM BRUTO, como string. Descodificá-lo e voltar a codificá-lo altera a ordem das chaves e os espaços em branco, e a assinatura deixa de corresponder, e é por isso que o payload tem o tipo string: um array, como o resultado de json_decode() ou a entrada já analisada por um framework, dá um TypeError em vez de ser verificado. Os cabeçalhos podem ser um array com o nome do cabeçalho em maiúsculas ou minúsculas, como o que getallheaders() devolve, $_SERVER, onde o cabeçalho chega como HTTP_X_OPENEMAIL_SIGNATURE, qualquer iterável, como um HeaderBag do Symfony ou do Laravel, ou um pedido PSR-7. Um valor que seja um array é lido a partir do primeiro elemento.
Duas coisas que isto trata e que uma verificação feita à mão costuma não tratar: compara o MAC em tempo constante com hash_equals(), para que o prefixo correto não possa ser recuperado cronometrando-o, e rejeita uma entrega com mais de toleranceSeconds: de diferença em qualquer das direções, cinco minutos salvo indicação em contrário, para que um pedido capturado não possa ser reutilizado para sempre. toleranceSeconds: 0 desliga a verificação de repetição. Ambos os bugs são silenciosos. Um handler com qualquer um deles passa em todos os testes que lhe ocorreria escrever.
Lança OpenEmail\Exception\WebhookSignatureException em todas as falhas: um cabeçalho X-OpenEmail-Signature em falta, um que não esteja na forma t=<seconds>,v1=<hex>, um carimbo temporal fora da janela, uma assinatura que não corresponde, ou um corpo assinado que não é um objeto JSON. Em caso de sucesso, devolve o corpo descodificado num array com chaves em camelCase, com id, type, createdAt e data, por isso não há um segundo json_decode() para errar.
Um segredo vazio lança antes OpenEmail\Exception\InvalidArgumentException, porque isso é um erro na sua configuração e não uma entrega inválida, e o (string) antes de getenv() transforma uma variável não definida exatamente nisso. Apanhe apenas WebhookSignatureException e responda 400. Assim, um segredo em falta termina o script com uma exceção não apanhada, a que o PHP em produção responde com um 500, e a entrega é tentada de novo depois de o corrigir, em vez de todos os eventos serem rejeitados como falsificados.
A verificação assenta em hash_hmac() e hash_equals(), que todas as compilações de PHP têm, por isso não precisa de nenhuma extensão. O exemplo faz return em vez de chamar exit, por isso as mesmas linhas funcionam também dentro de uma função ou de um front controller.
Numa aplicação PSR-7
Com um pedido PSR-7, como o que o Slim e o Mezzio lhe entregam, passe o próprio pedido como cabeçalhos e o seu corpo como 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);}Responda depressa: uma entrega que não obtenha resposta em 5 segundos conta como falhada e é enviada de novo mais tarde, por isso passe o trabalho a uma fila e responda com um 2xx. Os controladores de Laravel e de Symfony, com a passagem para a fila, estão na página de frameworks.
OpenEmail\Constants\WebhookSignatureHeaders nomeia os três cabeçalhos que uma entrega traz: X-OpenEmail-Signature, X-OpenEmail-Event com o tipo do evento, e X-OpenEmail-Delivery com o seu id, o mesmo id que está no corpo. Esse id mantém-se igual em todas as repetições e reenvios do evento, por isso é esse que deve guardar quando ignora eventos que já tratou.
Durante a rotação do segredo
webhooks->rotateSecret não tem janela de sobreposição: as entregas são assinadas com o novo segredo a partir do momento em que a chamada retorna. Primeiro faça o deploy de um recetor que aceite qualquer um dos segredos, depois rode, guarde o novo segredo onde o recetor o lê e, por fim, abandone o antigo.
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 envia um evento sintético assinado, por isso chame-o depois da rotação para provar que o novo segredo é verificado antes de remover o antigo.