Pular para o conteúdo
LeztyPay docs
Painel

Assinatura

Seu endpoint de webhook é uma URL pública: qualquer um pode fazer POST nela. A assinatura é o que separa um evento nosso de um transaction.paid forjado por alguém que descobriu o seu endereço. Verifique sempre, inclusive no ambiente de teste, senão você descobre que esqueceu justamente quando for para produção.

O header

Toda entrega traz:

X-Signature: t=1789041871,v1=5f1e9c...
  • t é o instante em que a entrega foi montada, em segundos desde a época Unix.
  • v1 é o HMAC-SHA256, em hexadecimal minúsculo, da string t + . + corpo cru, usando o segredo do endpoint (whsec_…) como chave.
  • Pode vir mais de um v1=. Ver "Rotação" abaixo.

Em pseudocódigo, o que nós calculamos:

assinatura = hex(hmac_sha256(chave = segredo, mensagem = t + "." + corpo_cru))

O corpo cru é o corpo cru

O corpo_cru são os bytes exatos que chegaram na requisição, na ordem em que chegaram. Não é o objeto desserializado, não é o objeto reserializado, não é o JSON reformatado pelo seu framework. Reserializar troca a ordem das chaves ou o espaçamento, muda um byte e o HMAC não fecha.

Esse é o erro que mais aparece, e ele depende do framework:

  • Express: monte a rota com express.raw({ type: 'application/json' }). express.json() descarta os bytes originais.
  • Fastify: registre um addContentTypeParser que guarde o buffer.
  • Flask: request.get_data() antes de tocar em request.json.
  • Django: request.body.
  • Laravel: $request->getContent(), nunca $request->all().
  • PHP puro: file_get_contents('php://input').

Detalhes por stack Node em verificar em Node.

Janela de tempo

Recuse a entrega se |agora - t| > 300 segundos. Sem essa checagem, uma requisição nossa capturada hoje pode ser reenviada por um atacante daqui a um mês com a assinatura ainda válida.

São 300 s para os dois lados, não só para o passado: relógio adiantado no seu servidor também derruba a verificação. Se você começar a ver rejeições por tempo em massa, olhe o NTP da sua máquina antes de suspeitar da assinatura.

Comparação em tempo constante

Compare o hexadecimal esperado com o recebido usando a função de tempo constante da sua linguagem (crypto.timingSafeEqual, hmac.compare_digest, hash_equals), nunca ==. Comparação normal para no primeiro byte diferente, e a diferença de tempo entre "errou no primeiro byte" e "errou no trigésimo" é medível pela rede: dá para reconstruir a assinatura byte a byte.

Duas armadilhas na hora de usar essas funções:

  1. timingSafeEqual do Node lança exceção se os buffers tiverem tamanhos diferentes. Compare o comprimento antes, e compare os hexadecimais como bytes ASCII, sem decodificar (hex inválido seria truncado em silêncio).
  2. Não saia do laço no primeiro acerto quando houver várias assinaturas. Parar cedo devolve pelo tempo qual delas bateu.

Rotação: duas assinaturas ao mesmo tempo

Ao rotacionar o segredo você recebe o novo e um previous_secret_expires_at 24 h à frente. Durante essa janela toda entrega sai com dois v1=, o primeiro com o segredo novo e o segundo com o antigo:

X-Signature: t=1789041871,v1=<assinatura com o segredo novo>,v1=<assinatura com o antigo>

A verificação certa é: aceite se qualquer v1= bater com qualquer segredo que você conhece. Assim o seu receptor continua funcionando com o segredo antigo enquanto você faz o deploy do novo, e volta a ter um só depois. Quem verifica apenas o primeiro v1= quebra na rotação; quem verifica o header inteiro como se fosse uma string única nunca funciona.

Passadas as 24 h o segredo antigo é apagado e as entregas voltam a ter um v1= só.

Verificação pronta

Estes arquivos são executáveis e não têm dependência nenhuma. Cada um roda o próprio autoteste: assina um payload fixo localmente e confere corpo adulterado, t fora da janela, header malformado e o caso das duas assinaturas da rotação.

Em Node, o exemplo completo (handler cru e middleware de Express) está em verificar em Node.

Python

python3 verify-webhook.py roda o autoteste.

#!/usr/bin/env python3
"""Verificação da assinatura X-Signature de um webhook da LeztyPay, em Python 3.

Zero dependência: só a biblioteca padrão. Rode `python3 verify-webhook.py` para o
autoteste (assina um payload fixo localmente, sem rede).

O que é assinado é `<t>.<bytes crus do corpo>`. Pegue o corpo antes de qualquer parser
de JSON, senão o hash não fecha:

    Flask     raw = request.get_data(); header = request.headers.get('X-Signature')
    Django    raw = request.body;       header = request.META.get('HTTP_X_SIGNATURE')
    FastAPI   raw = await request.body(); header = request.headers.get('x-signature')
"""

import hashlib
import hmac
import json
import os
import time

# Janela aceita para o `t=` do header, em segundos.
TOLERANCE_SECONDS = 300

# Durante as 24 h de graça da rotação mantenha os dois: o emissor manda
# `v1=<novo>,v1=<antigo>` e basta um bater.
SECRETS = [
    value
    for value in (
        os.environ.get("LM_WEBHOOK_SECRET"),
        os.environ.get("LM_WEBHOOK_SECRET_PREVIOUS"),
    )
    if value
]


def verify_signature(header, raw_body, secrets, now_sec=None, tolerance_sec=TOLERANCE_SECONDS):
    """Devolve (True, 'ok') ou (False, 'malformed' | 'expired' | 'mismatch')."""
    now_sec = int(time.time()) if now_sec is None else now_sec

    timestamp = None
    given = []
    for part in (header or "").split(","):
        piece = part.strip()
        if piece.startswith("t="):
            try:
                timestamp = int(piece[2:])
            except ValueError:
                return False, "malformed"
        elif piece.startswith("v1="):
            given.append(piece[3:])
    if timestamp is None or not given:
        return False, "malformed"
    if abs(now_sec - timestamp) > tolerance_sec:
        return False, "expired"

    body = raw_body if isinstance(raw_body, bytes) else raw_body.encode("utf-8")
    signed = f"{timestamp}.".encode("utf-8") + body

    matched = False
    for secret in secrets:
        expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
        for candidate in given:
            # compare_digest é comparação em tempo constante e aceita tamanhos diferentes.
            # Sem `break`: avaliar todas as combinações evita vazar por tempo qual bateu.
            if hmac.compare_digest(candidate, expected):
                matched = True
    return (True, "ok") if matched else (False, "mismatch")


def handle_request(header, raw_body, on_event, secrets=None):
    """Devolve o status HTTP que o seu receptor deve responder.

    Responda 2xx em menos de 10 s: `on_event` deve só enfileirar o evento. Exceção não
    tratada vira 500 e a entrega é retentada.
    """
    ok, reason = verify_signature(header, raw_body, SECRETS if secrets is None else secrets)
    if not ok:
        return 401, reason
    on_event(json.loads(raw_body))
    return 200, "ok"


def _self_test():
    secret = "whsec_exemploDeSecretDeEndpoint"
    raw_body = '{"id":"evt_1","object":"event","type":"transaction.paid"}'
    t = 1789041871

    def sign(key):
        signed = f"{t}.{raw_body}".encode("utf-8")
        return hmac.new(key.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    header = f"t={t},v1={sign(secret)}"

    assert verify_signature(header, raw_body, [secret], now_sec=t) == (True, "ok")
    # Um byte a mais no corpo já derruba o hash.
    assert verify_signature(header, raw_body + " ", [secret], now_sec=t)[1] == "mismatch"
    # Fora da janela de 300 s, mesmo com assinatura boa.
    assert verify_signature(header, raw_body, [secret], now_sec=t + 301)[1] == "expired"
    assert verify_signature(f"v1={sign(secret)}", raw_body, [secret], now_sec=t)[1] == "malformed"
    assert verify_signature(f"t={t}", raw_body, [secret], now_sec=t)[1] == "malformed"
    # Rotação: chegam duas assinaturas e o receptor só conhece a antiga.
    rotacao = f"t={t},v1={sign('whsec_secretNovoQueVoceAindaNaoGuardou')},v1={sign(secret)}"
    assert verify_signature(rotacao, raw_body, [secret], now_sec=t) == (True, "ok")
    # Bytes ou str dão o mesmo resultado.
    assert verify_signature(header, raw_body.encode("utf-8"), [secret], now_sec=t) == (True, "ok")

    print("verify-webhook: 7 asserções ok")


if __name__ == "__main__":
    _self_test()

PHP

php verify-webhook.php roda o autoteste.

<?php

// Verificação da assinatura X-Signature de um webhook da LeztyPay, em PHP 8.
//
// Zero dependência: só funções nativas. Rode `php verify-webhook.php` para o autoteste
// (assina um payload fixo localmente, sem rede).
//
// O que é assinado é `<t>.<bytes crus do corpo>`. Em PHP o corpo cru é `php://input` e o
// header chega em `$_SERVER['HTTP_X_SIGNATURE']`. Em Laravel use `$request->getContent()`
// e `$request->header('X-Signature')`; nunca `$request->all()`, que já perdeu os bytes.

declare(strict_types=1);

/** Janela aceita para o `t=` do header, em segundos. */
const LEZTYPAY_TOLERANCE_SECONDS = 300;

/**
 * @param string[] $secrets Durante as 24 h de graça da rotação mantenha os dois: o
 *                          emissor manda `v1=<novo>,v1=<antigo>` e basta um bater.
 * @return string 'ok' | 'malformed' | 'expired' | 'mismatch'
 */
function leztypay_verify_signature(
    ?string $header,
    string $rawBody,
    array $secrets,
    ?int $nowSec = null,
    int $toleranceSec = LEZTYPAY_TOLERANCE_SECONDS
): string {
    $nowSec ??= time();

    $timestamp = null;
    $given = [];
    foreach (explode(',', (string) $header) as $part) {
        $piece = trim($part);
        if (str_starts_with($piece, 't=')) {
            $value = substr($piece, 2);
            if ($value === '' || !ctype_digit($value)) {
                return 'malformed';
            }
            $timestamp = (int) $value;
        } elseif (str_starts_with($piece, 'v1=')) {
            $given[] = substr($piece, 3);
        }
    }
    if ($timestamp === null || $given === []) {
        return 'malformed';
    }
    if (abs($nowSec - $timestamp) > $toleranceSec) {
        return 'expired';
    }

    $matched = false;
    foreach ($secrets as $secret) {
        $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
        foreach ($given as $candidate) {
            // hash_equals é comparação em tempo constante. Sem `break`: avaliar todas as
            // combinações evita vazar por tempo qual assinatura bateu.
            if (hash_equals($expected, $candidate)) {
                $matched = true;
            }
        }
    }

    return $matched ? 'ok' : 'mismatch';
}

/** @return string[] */
function leztypay_secrets(): array
{
    $secrets = [getenv('LM_WEBHOOK_SECRET'), getenv('LM_WEBHOOK_SECRET_PREVIOUS')];

    return array_values(array_filter($secrets, static fn ($secret) => is_string($secret) && $secret !== ''));
}

/**
 * Receptor cru. Responda 2xx em menos de 10 s: `$onEvent` deve só enfileirar o evento.
 */
function leztypay_handle_request(callable $onEvent): void
{
    $rawBody = (string) file_get_contents('php://input');
    $header = $_SERVER['HTTP_X_SIGNATURE'] ?? null;
    $result = leztypay_verify_signature($header, $rawBody, leztypay_secrets());

    if ($result !== 'ok') {
        http_response_code(401);
        echo $result;

        return;
    }

    $onEvent(json_decode($rawBody, true), $_SERVER['HTTP_X_EVENT_ID'] ?? null);
    http_response_code(200);
}

function leztypay_self_test(): void
{
    $secret = 'whsec_exemploDeSecretDeEndpoint';
    $rawBody = '{"id":"evt_1","object":"event","type":"transaction.paid"}';
    $t = 1789041871;
    $sign = static fn (string $key): string => hash_hmac('sha256', $t . '.' . $rawBody, $key);
    $header = 't=' . $t . ',v1=' . $sign($secret);

    $cases = [
        ['ok', leztypay_verify_signature($header, $rawBody, [$secret], $t)],
        // Um byte a mais no corpo já derruba o hash.
        ['mismatch', leztypay_verify_signature($header, $rawBody . ' ', [$secret], $t)],
        // Fora da janela de 300 s, mesmo com assinatura boa.
        ['expired', leztypay_verify_signature($header, $rawBody, [$secret], $t + 301)],
        ['malformed', leztypay_verify_signature('v1=' . $sign($secret), $rawBody, [$secret], $t)],
        ['malformed', leztypay_verify_signature('t=' . $t, $rawBody, [$secret], $t)],
        // Rotação: chegam duas assinaturas e o receptor só conhece a antiga.
        ['ok', leztypay_verify_signature(
            't=' . $t . ',v1=' . $sign('whsec_secretNovoQueVoceAindaNaoGuardou') . ',v1=' . $sign($secret),
            $rawBody,
            [$secret],
            $t
        )],
    ];

    foreach ($cases as $index => [$esperado, $obtido]) {
        if ($esperado !== $obtido) {
            fwrite(STDERR, "caso {$index}: esperava {$esperado}, veio {$obtido}\n");
            exit(1);
        }
    }

    echo 'verify-webhook: ' . count($cases) . " asserções ok\n";
}

if (PHP_SAPI === 'cli') {
    leztypay_self_test();
}

Depois que a assinatura bate

Verificar a assinatura prova a origem e a integridade, não a unicidade: o mesmo evento assinado corretamente pode chegar duas vezes. A deduplicação é um passo à parte, em idempotência e ordem.