# Conferir a assinatura (HMAC)

Toda entrega dos webhooks da Orkesio ([`/webhooks`](/endpoint/post/webhooks) e o
[webhook global](/endpoint/patch/server~webhook)) vem assinada com HMAC-SHA256, no padrão
[Standard Webhooks](https://www.standardwebhooks.com). 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

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`):

```js
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`):

```python
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](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries).

## À mão

### Node

```js
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

```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)

```php
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.
