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

HeaderO que é
Webhook-IdId do evento (o id do envelope). Igual nas novas tentativas: use para descartar repetidos.
Webhook-TimestampQuando a entrega saiu, em segundos Unix.
Webhook-Signaturev1, e a assinatura em base64. Pode trazer mais de uma, separadas por espaço.

Como conferir

  1. Pegue o corpo cru, como chegou, antes de converter o JSON. Qualquer espaço a mais muda a assinatura.
  2. Monte o texto <Webhook-Id>.<Webhook-Timestamp>.<corpo>.
  3. Calcule o HMAC-SHA256 desse texto com a chave e codifique em base64.
  4. Compare com cada assinatura do Webhook-Signature (sem o v1,), em tempo constante. Uma igual basta.
  5. Recuse se o Webhook-Timestamp estiver 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
secretwhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
Webhook-Idevt_01JAB3K9ZQ7V0M4T8R2X6WYD5H
Webhook-Timestamp1792238400
Corpo{"id":"evt_01JAB3K9ZQ7V0M4T8R2X6WYD5H","type":"message.received"}
Webhook-Signaturev1,aHWsNwVwwLqvXujAn6MqCw9T6S/RwVEs/qXHaOlqJSg=

Erros comuns

SintomaCausa
Nunca bateUsou o texto do secret como chave, sem tirar o whsec_ e decodificar de base64.
Bate às vezesConverteu o JSON e serializou de novo antes de assinar: use o corpo cru.
Para de bater depois de um tempoO 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.