# Verificação de assinatura de webhook

Este guia explica como a Fluxis assina webhooks de saída e como verificar que (1) a requisição veio da Fluxis e (2) o corpo JSON não foi alterado.

Referência de implementação: `src/pkg/utils/crypto_signature.go` (`GenerateRequestSignature`, `VerifyRequestSignature`, `toCanonicalJSON`).

Relacionado: [Visão geral de webhooks](/pt/docs/developers/webhooks) · [Guia de integração API](/pt/docs/developers/tutorials/api-integration-guide)

::::note Webhooks em nível de conta
URLs de webhook e segredos de assinatura estão vinculados a uma **conta** (`POST /v1/account/{accountID}/webhook`), não a Pontos de Venda individuais. Eventos de pagamento incluem `point_of_sale_id` no payload quando relevante.
::::

## O que a verificação comprova

| Propriedade    | Significado                                                                 |
| ----------- | ----------------------------------------------------------------------- |
| Autenticidade | Apenas quem tem seu `secret` de webhook poderia produzir uma assinatura válida |
| Integridade    | O corpo JSON não foi modificado em trânsito                               |

::::warning
Rejeite requisições que falharem na verificação. Não processe a entrega do pagamento até que assinatura e timestamp passem nas checagens.
::::

## Pré-requisitos

1. **Segredo de webhook** — Retornado uma vez na resposta de `POST /v1/account/{accountID}/webhook`. Armazene com segurança; não pode ser recuperado depois.
2. **Endpoint HTTPS** — Seu servidor deve aceitar `POST` com `Content-Type: application/json`.
3. **Corpo parseado** — Faça parse do JSON primeiro, depois re-canonicalize para assinar. Nunca assine bytes brutos da requisição.

## Algoritmo

A Fluxis usa HMAC-SHA256 sobre uma string determinística construída a partir do timestamp e do corpo do webhook:

```
signed_string = "<unix_timestamp_seconds>." + canonical_json(body)
signature     = hex( HMAC-SHA256( webhook_secret, signed_string ) )
```

**Cabeçalhos de entrega:**

| Cabeçalho                | Valor                                      |
| --------------------- | ------------------------------------------ |
| `X-Request-Signature` | HMAC-SHA256 codificado em hexadecimal                    |
| `X-Request-Timestamp` | Segundos Unix (string, ex.: `"1712345678"`) |
| `Content-Type`        | `application/json`                         |

**Validação de timestamp:** Rejeite se `now - timestamp > 300` segundos (`WEBHOOK_MAX_AGE_SECONDS`). Timestamps futuros **não** são rejeitados na implementação atual do servidor — você pode adicionar checagens mais rígidas no handler, se desejar.

## Objeto assinado

Assine o **corpo completo do webhook** como objeto JSON, tipicamente:

```json
{
  "data": {
    "notification_type": "payment_request_status_update",
    "payment_request_id": "aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf",
    "payment_request_type": "fixed",
    "external_reference_id": "order-123",
    "status": "completed",
    "point_of_sale_id": "3cc1f7ca-c017-4e7e-b00d-aca0da6e464e",
    "account_id": "c87464d1-c54a-48c3-80c9-bee4fddf593d"
  }
}
```

**Não** assine apenas `data`, e **não** assine a string do corpo HTTP bruto antes do parse.

## Regras de JSON canônico

A Fluxis constrói JSON canônico via `toCanonicalJSON`:

1. Serializa o valor em JSON (tags de struct e `omitempty` aplicam — campos nil/zero são omitidos).
2. Deserializa em `map[string]any` / arrays para que objetos aninhados virem maps.
3. Serializa novamente — `json.Marshal` do Go em maps produz **JSON compacto** (sem espaços) com chaves **ordenadas alfabeticamente em cada nível de objeto**.

Seu verificador deve reproduzir esse comportamento:

| Regra | Detalhe |
| ---- | ------ |
| Compacto | Sem espaços: `{"a":1}` não `{ "a": 1 }` |
| Chaves ordenadas | Em cada nível de objeto, incluindo `data` aninhado |
| Como recebido | Campos opcionais ausentes no payload devem permanecer ausentes na forma canônica |
| Não bytes brutos | Middleware que reformata JSON antes de verificar quebra assinaturas |

::::note omitempty
Se `external_reference_id` ou `account_id` não estiver definido, essas chaves são **omitidas** do JSON que a Fluxis assina. Reconstruir o corpo a partir de um esquema fixo que sempre inclui essas chaves produzirá assinatura diferente.
::::

## Checklist de verificação

1. Leia `X-Request-Signature` e `X-Request-Timestamp` dos cabeçalhos.
2. Faça parse do corpo da requisição em um objeto genérico (`map` / `dict`).
3. Rejeite se `now - timestamp > 300`.
4. Serialize o objeto parseado em JSON canônico (chaves ordenadas, compacto).
5. Monte `signed_string = timestamp + "." + canonical_json`.
6. Calcule `HMAC-SHA256(secret, signed_string)`, codifique em hex e compare com igualdade de tempo constante (após checagem de tamanho).

## Exemplo prático

Use estes valores fixos para testar sua implementação:

| Entrada | Valor |
| ----- | ----- |
| Secret | `whsec_example_for_documentation_only` |
| Timestamp | `1712345678` |
| Body | Veja JSON abaixo |

**Corpo (objeto parseado):**

```json
{
  "data": {
    "account_id": "c87464d1-c54a-48c3-80c9-bee4fddf593d",
    "external_reference_id": "order-123",
    "notification_type": "payment_request_status_update",
    "payment_request_id": "aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf",
    "payment_request_type": "fixed",
    "point_of_sale_id": "3cc1f7ca-c017-4e7e-b00d-aca0da6e464e",
    "status": "completed"
  }
}
```

**JSON canônico** (uma linha):

```
{"data":{"account_id":"c87464d1-c54a-48c3-80c9-bee4fddf593d","external_reference_id":"order-123","notification_type":"payment_request_status_update","payment_request_id":"aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf","payment_request_type":"fixed","point_of_sale_id":"3cc1f7ca-c017-4e7e-b00d-aca0da6e464e","status":"completed"}}
```

**String assinada:**

```
1712345678.{"data":{"account_id":"c87464d1-c54a-48c3-80c9-bee4fddf593d","external_reference_id":"order-123","notification_type":"payment_request_status_update","payment_request_id":"aa5d6c80-0b0b-4a2a-bbc8-14a41e6245bf","payment_request_type":"fixed","point_of_sale_id":"3cc1f7ca-c017-4e7e-b00d-aca0da6e464e","status":"completed"}}
```

**Assinatura esperada (hex):**

```
174b7c02c84d9112e8013336ea632ae056b1c468387eca1815d3b1c24f5c0c49
```

Se sua implementação produzir essa assinatura para as entradas acima, ela coincide com a assinatura de produção da Fluxis.

---

## Exemplos de código

### Node.js / TypeScript

```typescript
import crypto from "crypto";

function sortKeys(value: unknown): unknown {
  if (Array.isArray(value)) return value.map(sortKeys);
  if (value !== null && typeof value === "object") {
    return Object.keys(value as object)
      .sort()
      .reduce((acc, key) => {
        acc[key] = sortKeys((value as Record<string, unknown>)[key]);
        return acc;
      }, {} as Record<string, unknown>);
  }
  return value;
}

export function verifyWebhookSignature(
  payload: unknown,
  signature: string,
  timestamp: string,
  secret: string,
): boolean {
  const MAX_AGE_SECONDS = 300;
  const requestTimestamp = parseInt(timestamp, 10);
  if (Math.floor(Date.now() / 1000) - requestTimestamp > MAX_AGE_SECONDS) {
    return false;
  }

  const canonicalJson = JSON.stringify(sortKeys(payload));
  const signedString = `${timestamp}.${canonicalJson}`;
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedString)
    .digest("hex");

  if (signature.length !== expectedSignature.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(signature, "utf8"),
    Buffer.from(expectedSignature, "utf8"),
  );
}
```

::::warning timingSafeEqual no Node.js
`crypto.timingSafeEqual` lança erro se os tamanhos dos buffers diferirem. Compare tamanhos primeiro (como acima), ou use `hmac.compare_digest` em Python.
::::

### Python

```python
import hmac
import hashlib
import json
import time

def verify_webhook_signature(
    payload: dict,
    signature: str,
    timestamp: str,
    secret: str,
) -> bool:
    MAX_AGE_SECONDS = 300
    request_timestamp = int(timestamp)
    if int(time.time()) - request_timestamp > MAX_AGE_SECONDS:
        return False

    canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    signed_string = f"{timestamp}.{canonical_json}"
    expected_signature = hmac.new(
        secret.encode("utf-8"),
        signed_string.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(signature, expected_signature)
```

### Go

Corresponde a `toCanonicalJSON` e `VerifyRequestSignature` de produção:

```go
func canonicalJSON(v any) ([]byte, error) {
    raw, err := json.Marshal(v)
    if err != nil {
        return nil, err
    }
    var generic any
    if err := json.Unmarshal(raw, &generic); err != nil {
        return nil, err
    }
    return json.Marshal(generic)
}

func verifyWebhookSignature(
    payload map[string]any,
    signature, timestamp, secret string,
) (bool, error) {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false, err
    }
    if time.Now().Unix()-ts > 300 {
        return false, fmt.Errorf("timestamp too old")
    }

    jsonBytes, err := canonicalJSON(payload)
    if err != nil {
        return false, err
    }
    signedString := fmt.Sprintf("%s.%s", timestamp, jsonBytes)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedString))
    expected := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(signature), []byte(expected)), nil
}
```

---

## Testar seu handler

1. Registre um webhook: `POST /v1/account/{accountID}/webhook` com sua URL pública e `event_type: "payment_request"`.
2. Salve o `secret` da resposta de criação.
3. Chame **`POST /v1/account/{accountID}/webhook/{webhookID}/test`** — a Fluxis faz POST de um payload de amostra assinado na sua URL usando o mesmo caminho de código da produção (`GenerateRequestSignature` em `{ "data": … }`).
4. Verifique a assinatura no handler e retorne HTTP `200`.
5. Opcionalmente compare com o cálculo manual usando o [exemplo prático](#exemplo-pratico) acima.

Veja tentativas de entrega em **`GET /v1/account/{accountID}/webhook/logs?page=1&page_size=20`**.

---

## Falhas comuns

| Sintoma | Causa provável |
| ------- | ------------ |
| Assinatura sempre inválida | Segredo incorreto (não o da criação); assinar bytes brutos em vez de JSON parseado |
| Inválida intermitente | Campos opcionais adicionados/removidos vs o que foi assinado; middleware alterando `req.body` |
| Assinatura válida, rejeitada mesmo assim | Diferença de relógio além de 300 s; usar timestamp errado (deve coincidir com o cabeçalho `X-Request-Timestamp`) |
| Handler Node falha na comparação | `timingSafeEqual` chamado com strings hex de tamanhos diferentes — adicione checagem de tamanho primeiro |
| Divergência em Go com structs aninhados | Assinar struct sem round-trip por `any` — use o padrão `canonicalJSON` acima |
| Teste funciona, produção falha | Payload de produção sem campos que seu vetor de teste sempre inclui |

## Notas de segurança

- Use comparação de **tempo constante** (`hmac.Equal`, `hmac.compare_digest`, `timingSafeEqual` com buffers de mesmo tamanho).
- **Rejeite timestamps obsoletos** (mais antigos que 300 segundos).
- **Processe a lógica de negócio somente depois** que a verificação de assinatura for bem-sucedida.
- Armazene o segredo de webhook em um gerenciador de segredos, não no controle de versão.
- Use HTTPS no endpoint; trate o segredo como credencial de API.

---

## Precisa de ajuda?

Entre em contato em [support@fluxis.us](mailto:support@fluxis.us) com uma cópia redigida do corpo recebido, cabeçalhos (omita o segredo) e seu JSON canônico calculado se a verificação falhar.
