Conferir a assinatura (HMAC)
Toda entrega dos webhooks da Orkesio (/webhooks e o
webhook global) vem assinada com HMAC-SHA256, no padrão
Standard Webhooks. Conferir a assinatura prova que a entrega saiu da Orkesio e que
o corpo não foi alterado no caminho. Faça isso antes de confiar no evento.
O segredo
Cada webhook tem um secret próprio (whsec_ e 24 bytes em base64). Ele vem inteiro só na criação e quando é
trocado com rotate_secret: true; nas outras respostas vem mascarado. Guarde-o como uma senha.
A chave do HMAC é a parte depois de whsec_, decodificada de base64 (os 24 bytes), não o texto.
Os headers
| Header | O que é |
|---|---|
Webhook-Id | Id do evento (o id do envelope). Igual nas novas tentativas: use para descartar repetidos. |
Webhook-Timestamp | Quando a entrega saiu, em segundos Unix. |
Webhook-Signature | v1, e a assinatura em base64. Pode trazer mais de uma, separadas por espaço. |
Como conferir
- Pegue o corpo cru, como chegou, antes de converter o JSON. Qualquer espaço a mais muda a assinatura.
- Monte o texto
<Webhook-Id>.<Webhook-Timestamp>.<corpo>. - Calcule o HMAC-SHA256 desse texto com a chave e codifique em base64.
- Compare com cada assinatura do
Webhook-Signature(sem ov1,), em tempo constante. Uma igual basta. - Recuse se o
Webhook-Timestampestiver a mais de 5 minutos do seu relógio: evita que alguém reenvie uma entrega antiga.
Responda 401 quando a assinatura não bater, e não processe o evento.
Com a biblioteca oficial
O Standard Webhooks tem bibliotecas prontas, que fazem os 5 passos. Em Node (npm install standardwebhooks):
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.ORKESIO_WEBHOOK_SECRET); // whsec_...
const evento = wh.verify(corpoCru, headers); // lança erro se a assinatura não bater
Em Python (pip install standardwebhooks):
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["ORKESIO_WEBHOOK_SECRET"]) # whsec_...
evento = wh.verify(corpo_cru, headers) # lança erro se a assinatura não bater
Há bibliotecas também para Go, PHP, Ruby, Java, C# e Rust, no repositório do Standard Webhooks.
À mão
Node
import crypto from "node:crypto";
function assinaturaValida(secret, headers, corpoCru) {
const id = headers["webhook-id"], ts = headers["webhook-timestamp"];
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const chave = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const esperada = Buffer.from(
crypto.createHmac("sha256", chave).update(`${id}.${ts}.${corpoCru}`).digest("base64"),
);
return (headers["webhook-signature"] || "").split(" ").some((s) => {
const recebida = Buffer.from(s.replace(/^v1,/, ""));
return s.startsWith("v1,") && recebida.length === esperada.length && crypto.timingSafeEqual(recebida, esperada);
});
}
Python
import base64, hashlib, hmac, time
def assinatura_valida(secret: str, headers: dict, corpo_cru: bytes) -> bool:
wid, ts = headers.get("webhook-id"), headers.get("webhook-timestamp", "")
if not wid or not (ts.isascii() and ts.isdigit() and len(ts) <= 12) or abs(time.time() - int(ts)) > 300:
return False
chave = base64.b64decode(secret.removeprefix("whsec_"))
texto = f"{wid}.{ts}.".encode() + corpo_cru
esperada = base64.b64encode(hmac.new(chave, texto, hashlib.sha256).digest()).decode()
return any(
s.startswith("v1,") and hmac.compare_digest(s[3:].encode(), esperada.encode())
for s in headers.get("webhook-signature", "").split(" ")
)
PHP (8 ou mais)
function assinaturaValida(string $secret, array $headers, string $corpoCru): bool {
$id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? '';
if ($id === '' || $ts === '' || abs(time() - (int) $ts) > 300) return false;
$chave = base64_decode(substr($secret, strlen('whsec_')));
$esperada = base64_encode(hash_hmac('sha256', "$id.$ts.$corpoCru", $chave, true));
foreach (explode(' ', $headers['webhook-signature'] ?? '') as $s) {
if (str_starts_with($s, 'v1,') && hash_equals($esperada, substr($s, 3))) return true;
}
return false;
}
Para testar o seu código
Com estes valores, a assinatura certa é a da última linha. Se o seu código chegar nela, a conta está certa (para o teste, desligue a checagem dos 5 minutos).
| Valor | |
|---|---|
secret | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
Webhook-Id | evt_01JAB3K9ZQ7V0M4T8R2X6WYD5H |
Webhook-Timestamp | 1792238400 |
| Corpo | {"id":"evt_01JAB3K9ZQ7V0M4T8R2X6WYD5H","type":"message.received"} |
Webhook-Signature | v1,aHWsNwVwwLqvXujAn6MqCw9T6S/RwVEs/qXHaOlqJSg= |
Erros comuns
| Sintoma | Causa |
|---|---|
| Nunca bate | Usou o texto do secret como chave, sem tirar o whsec_ e decodificar de base64. |
| Bate às vezes | Converteu o JSON e serializou de novo antes de assinar: use o corpo cru. |
| Para de bater depois de um tempo | O secret foi trocado (rotate_secret): atualize o seu. |
Os webhooks no formato da uazapi (/webhook, eventos sem ponto) não são assinados: para eles, use um endereço difícil
de adivinhar, com um segredo na URL.