<!-- Source: https://docs.infraio.xyz/pt-BR/webhooks/signature-verification -->
<!-- Last updated: 2026-10-04 -->

# Verificação de assinatura

Toda entrega de webhook inclui dois headers usados em conjunto:

```http
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000
```

A string em hex depois de `sha256=` é
`HMAC-SHA256(secret, timestamp + "." + raw_body)`. O ponto é um byte
literal; o timestamp é unix-segundos em ASCII.

## Por que verificar

URLs de webhook podem vazar por logs de proxy, screenshots,
histórico de navegador e tickets de suporte. Sem uma
checagem de assinatura, qualquer um que descobrir a sua URL pode
fazer POST de um `payment.settled` falso e te enganar para entregar
pedidos não pagos. A verificação prova que a
requisição veio da InfraIO Pay.

Incluir o timestamp dentro do payload assinado também te dá
**proteção contra replay**: um atacante que captura uma entrega não
consegue reenviá-la depois sem que a assinatura se torne
detectavelmente velha.

## O algoritmo

```
signed_payload  = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)
```

Depois confira que o timestamp está recente (tolerância típica: ±5
minutos).

> **Warning:**
>
> Sempre passe os bytes **brutos** do body da requisição. Frameworks
> costumam fazer parse do JSON antes do seu handler rodar; a versão
> re-stringificada pode diferir do que a gente enviou (ordem de
> chaves, espaços em branco, formatação numérica), e o HMAC não vai
> bater. No Next.js App Router use `await req.text()` *antes* de
> `JSON.parse`. No Express, monte
> `express.raw({ type: 'application/json' })` só na rota de webhook.

## Implementações

**Node / TS**

```ts filename="lib/verify-infraio.ts"
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyInfraIo({
  body,
  signature,
  timestamp,
  secret,
}: {
  body: string;       // texto bruto — NÃO JSON parseado
  signature: string;  // valor do header X-Signature
  timestamp: string;  // valor do header X-Timestamp (unix segundos)
  secret: string;     // whsec_…
}): boolean {
  const ts = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
    return false; // muito velho ou muito no futuro
  }

  const expected = "sha256=" + createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}
```

**Go**

```go
package infraio

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

// VerifyWebhook returns true iff sig matches
// HMAC-SHA256(secret, timestamp + "." + body) AND the timestamp
// is within the tolerance window.
func VerifyWebhook(body []byte, sig, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    skew := time.Now().Unix() - ts
    if skew < 0 {
        skew = -skew
    }
    if skew > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) == 1
}
```

**Python**

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    """body: raw bytes. signature: 'sha256=<hex>'. timestamp: unix seconds."""
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

**Ruby**

```ruby
require 'openssl'

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body, signature, timestamp, secret)
  ts = Integer(timestamp) rescue (return false)
  return false if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS

  signed   = "#{timestamp}.#{body}"
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
  Rack::Utils.secure_compare(signature.to_s, expected)
end
```

## Proteção contra replay

O timestamp dentro do payload assinado é a **primeira** linha de
defesa — um atacante que captura uma entrega não consegue reenviar
depois que a sua janela de tolerância expira.

Para mais segurança (recomendado para eventos de alto valor como
`payment.settled`):

1. **Deduplique em `X-Delivery`** numa tabela com uma constraint de
   unicidade. Replays dentro da janela de tolerância viram no-ops —
   o seu handler retorna 200 sem fazer o trabalho duas vezes. É a
   mesma idempotência que você quer para retentativas legítimas.
   (`X-Delivery` é estável em todas as retentativas de uma entrega;
   o payload não traz um campo `event_id`.)
2. **Use a menor tolerância que a sua defasagem de relógio permite.**
   ±5 minutos é o padrão recomendado e bate com o que a maioria das
   frotas sincronizadas via NTP consegue sustentar. Mais apertado
   tudo bem; abaixo de ±30 segundos você vai começar a rejeitar
   entregas legítimas em redes com NTP upstream lento.

## Rotacionando um segredo

1. **Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.**
2. Um segredo novo é gerado e mostrado **exatamente uma vez**. Copie
   antes de fechar a caixa de diálogo.
3. Atualize a sua env var e redeploye o seu verificador dentro de 24
   horas.

### Janela de tolerância (assinatura dupla)

Pelas **24 horas** depois de uma rotação, toda entrega leva duas
assinaturas:

```http
X-Signature:       sha256=<hmac(new_secret,  ts + "." + body)>
X-Signature-Prev:  sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp:       1729536000
```

Um verificador rodando o segredo **anterior** combina com
`X-Signature-Prev`; um verificador rodando o segredo **novo**
combina com `X-Signature`. Qualquer um dos headers passando já é
suficiente — o seu handler pode aceitar a entrega durante a migração
sem segurar o deploy.

Depois que a janela de tolerância fecha, só `X-Signature` é enviado.
O segredo anterior para de ser aceito e qualquer verificador ainda
configurado com ele vai começar a rejeitar entregas — então termine
o seu rollout dentro do orçamento de 24 horas.

### Padrão sugerido de receptor

```ts
// Aceita qualquer das duas assinaturas durante uma janela de tolerância de rotação.
const sig     = req.headers["x-signature"]      ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
       || (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));
```

Você pode descartar o ramo `X-Signature-Prev` assim que a janela de
tolerância no seu endpoint tiver expirado e você tiver removido o
`PREV_SECRET` do seu env.

### Revogação emergencial

Se um segredo vazou publicamente e você precisa invalidar o segredo
anterior imediatamente — ou seja, você não quer que a sobreposição
de 24 horas mantenha uma chave conhecidamente comprometida viva —
rotacione duas vezes. A primeira rotação move o segredo vazado para
o slot anterior; a segunda rotação empurra para fora do slot
anterior (substituindo pela chave ainda-nova) para que o valor
vazado não seja mais aceito.

## Testando o seu wiring

No dashboard, abra **Developers → Webhooks** e clique em **Send
Test** no endpoint que você quer verificar. A gente assina e faz
POST de um envelope sintético para a URL de forma síncrona, e depois
mostra o status HTTP, latência e um snippet de 512 bytes da sua
resposta. Formato do payload:

```json
{
  "event_id":   "<uuid>",
  "event_type": "webhook.test.ping",
  "created_at": "2026-05-17T12:00:00Z",
  "test":       true,
  "data": {
    "merchant_id": "<your-merchant-id>",
    "webhook_id":  "<endpoint-id>",
    "message":     "Test ping from the merchant dashboard..."
  }
}
```

O test ping usa o mesmo esquema de assinatura que entregas de
produção, então um check verde nesse botão confirma que o seu
verificador aceita eventos reais também. Test pings não são retentados. Para exercitar retries, dispare um
evento real pelo fluxo de API relevante.

## Falhas comuns

| Sintoma | Causa provável |
| --- | --- |
| Sempre retorna false em dev | O body foi JSON-parseado antes do HMAC. Leia os bytes brutos primeiro. |
| Funcionou ontem, falha hoje | Você rotacionou o segredo mas a env var neste servidor ainda tem o antigo. Redeploye com o segredo novo. |
| Falha para eventos antigos, funciona para novos | Uma entrega ficou na fila antes da rotação; a assinatura usa o segredo antigo e o seu verificador não aceita mais. Espere o retry derrubar ou replaye pelo dashboard. |
| Erro de unidade no timestamp | Confira que você compara unix-segundos com unix-segundos. `Date.now()` em JS é em **milissegundos** — divida por 1000. |
| Funciona local, falha em prod | Um proxy (Cloudflare, nginx) está descomprimindo, re-codificando ou retirando uma quebra de linha final. Inspecione os bytes que o seu handler vê. |
| Test ping diz 401 / signature mismatch | O seu verificador está assinando só `body`. Assine `timestamp + "." + body` no lugar. |
| Header totalmente ausente | O endpoint está registrado para um ambiente diferente. Endpoints de modo de teste só recebem eventos `environment=test`. |
