> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nyxpag.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Validando a assinatura dos webhooks

> Confira que o webhook veio da NyxPag: HMAC-SHA256 sobre timestamp.corpo, comparação em tempo constante, janela anti-replay e exemplos em Node, Python e PHP.

Qualquer pessoa que descubra a URL do seu webhook pode enviar um `POST` fingindo ser a NyxPag. A assinatura é o que impede isso: sem validar, um atacante marca um pedido como pago sem ter pago.

<Warning>
  Valide a assinatura **antes** de qualquer efeito colateral: liberar produto, creditar saldo, enviar e-mail. Se a validação falhar, descarte o evento.
</Warning>

## O que a NyxPag envia

Toda entrega é um `POST` com `Content-Type: application/json` e estes cabeçalhos:

| Cabeçalho | Exemplo | Para que serve |
| - | - | - |
| `X-NyxPag-Signature` | `t=1789056000,v1=9f5e68...` | Assinatura e timestamp da entrega |
| `X-NyxPag-Event` | `transaction.approved` | Nome do evento, igual ao campo `event` do corpo |
| `X-NyxPag-Delivery` | `a1b2c3...` | ID único da entrega, útil para deduplicar |
| `User-Agent` | `NyxPag-Webhooks/1.0` | Identifica a origem |

## Como a assinatura é calculada

A NyxPag calcula um HMAC-SHA256 sobre o texto `<t>.<corpo bruto>` e envia o resultado em hexadecimal no campo `v1`.

```text theme={"dark"}
X-NyxPag-Signature: t=<timestamp unix em segundos>,v1=<hmac em hexadecimal>

conteúdo assinado = t + "." + corpo bruto da requisição
chave             = segredo de assinatura do webhook
```

<Steps>
  <Step title="Separe t e v1">
    Divida o cabeçalho por vírgula e depois cada parte por `=`. Se faltar `t` ou `v1`, rejeite.
  </Step>

  <Step title="Cheque o timestamp">
    Rejeite se `t` estiver a mais de 5 minutos do seu relógio. Isso barra reenvio de uma entrega antiga capturada por terceiros.
  </Step>

  <Step title="Monte o conteúdo assinado">
    Concatene `t`, um ponto e o **corpo bruto**, byte a byte, exatamente como chegou.
  </Step>

  <Step title="Calcule o HMAC">
    HMAC-SHA256 com o segredo do webhook como chave, saída em hexadecimal.
  </Step>

  <Step title="Compare em tempo constante">
    Use a função segura da sua linguagem. Comparar com `==` vaza informação pelo tempo de resposta.
  </Step>
</Steps>

<Warning>
  O corpo precisa ser o **bruto**. Se o seu framework faz parse do JSON e você reserializa, espaços e ordem de chaves mudam e a assinatura nunca bate. No Express use `express.raw`, no Flask use `request.get_data()`, no PHP use `file_get_contents('php://input')`.
</Warning>

## Qual é a chave

A chave é o **segredo de assinatura do webhook**. Ele aparece uma única vez, na criação do endpoint em [Dashboard → Webhooks](https://nyxpag.com.br/dashboard/webhooks), e pode ser trocado com **Rotacionar segredo**. Guarde como qualquer credencial.

<Note>
  Chaves antigas que nunca tiveram um endpoint de webhook configurado assinam com o SHA-256 da API Key em hexadecimal, tratado como texto. Se a sua integração usa só `webhookUrl` por requisição e você não tem um segredo no painel, é esse o caso. Para sair dele, crie um endpoint em **Dashboard → Webhooks**.
</Note>

## Vetor de teste

Use estes valores para conferir sua implementação antes de ligar em produção. O resultado foi calculado de forma independente com OpenSSL e Node.js.

| Entrada | Valor |
| - | - |
| Segredo | `whsec_exemplo_nyxpag` |
| `t` | `1789056000` |
| Corpo bruto | `{"id":"evt_txn_exemplo_approved","event":"transaction.approved","createdAt":"2026-10-03T14:01:32.000Z","data":{"id":"txn_exemplo","status":"approved"}}` |
| `v1` esperado | `9f5e6880464b6149bb93f1ff9567f7ba034022f505afc84d0ea2637d0a437ea9` |

```bash cURL theme={"dark"}
SECRET='whsec_exemplo_nyxpag'
T=1789056000
BODY='{"id":"evt_txn_exemplo_approved","event":"transaction.approved","createdAt":"2026-10-03T14:01:32.000Z","data":{"id":"txn_exemplo","status":"approved"}}'

printf '%s' "${T}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" -hex
```

## Implementação

<CodeGroup>
  ```javascript Node.js (Express) theme={"dark"}
  import express from "express";
  import crypto from "node:crypto";

  const app = express();
  const secret = process.env.NYXPAG_WEBHOOK_SECRET;

  function verify(rawBody, header, toleranceSeconds = 300) {
    const parts = Object.fromEntries(
      header.split(",").map((kv) => kv.trim().split("=")),
    );
    const { t, v1 } = parts;
    if (!t || !v1) return false;

    if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.${rawBody}`, "utf8")
      .digest("hex");

    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(v1, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  app.post("/webhooks/nyxpag", express.raw({ type: "application/json" }), (req, res) => {
    const rawBody = req.body.toString("utf8");
    if (!verify(rawBody, req.header("X-NyxPag-Signature") ?? "")) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(rawBody);
    // 1. grave o evento de forma durável  2. responda 2xx  3. processe depois
    res.sendStatus(200);
  });
  ```

  ```python Python (Flask) theme={"dark"}
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = os.environ["NYXPAG_WEBHOOK_SECRET"].encode()


  def verify(raw_body: bytes, header: str, tolerance: int = 300) -> bool:
      parts = dict(item.strip().split("=", 1) for item in header.split(","))
      t, v1 = parts.get("t"), parts.get("v1")
      if not t or not v1:
          return False
      if abs(time.time() - int(t)) > tolerance:
          return False

      signed = t.encode() + b"." + raw_body
      expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, v1)


  @app.post("/webhooks/nyxpag")
  def nyxpag_webhook():
      if not verify(request.get_data(), request.headers.get("X-NyxPag-Signature", "")):
          abort(401)

      event = request.get_json()
      # grave o evento de forma durável, responda 2xx e processe depois
      return "", 200
  ```

  ```php PHP theme={"dark"}
  <?php
  $secret = getenv('NYXPAG_WEBHOOK_SECRET');
  $rawBody = file_get_contents('php://input');
  $header = $_SERVER['HTTP_X_NYXPAG_SIGNATURE'] ?? '';

  $parts = [];
  foreach (explode(',', $header) as $item) {
      [$k, $v] = array_pad(explode('=', trim($item), 2), 2, '');
      $parts[$k] = $v;
  }

  $t = $parts['t'] ?? '';
  $v1 = $parts['v1'] ?? '';

  if ($t === '' || $v1 === '' || abs(time() - (int) $t) > 300) {
      http_response_code(401);
      exit('invalid signature');
  }

  $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);

  if (!hash_equals($expected, $v1)) {
      http_response_code(401);
      exit('invalid signature');
  }

  $event = json_decode($rawBody, true);
  // grave o evento de forma durável, responda 2xx e processe depois
  http_response_code(200);
  ```
</CodeGroup>

## Erros comuns

<AccordionGroup>
  <Accordion title="A assinatura nunca bate">
    Quase sempre é o corpo: o framework fez parse e você reserializou, ou um middleware alterou o texto. Valide com o vetor de teste acima usando o corpo bruto. Confirme também que você está usando o segredo do webhook e não a API Key.
  </Accordion>

  <Accordion title="Funciona local e falha em produção">
    Proxies e gateways (Nginx, Cloudflare, API Gateway) às vezes reescrevem o corpo ou comprimem. Garanta que a rota do webhook receba o corpo sem transformação.
  </Accordion>

  <Accordion title="Rejeita todo evento por timestamp">
    O relógio do seu servidor está fora de hora. Sincronize com NTP. A tolerância de 5 minutos é sugestão sua, não da NyxPag: ajuste se precisar.
  </Accordion>

  <Accordion title="Troquei o segredo e os eventos pararam de validar">
    Depois de **Rotacionar segredo**, o valor antigo deixa de assinar. Atualize o segredo no seu servidor antes ou logo depois de rotacionar.
  </Accordion>
</AccordionGroup>

<CardGroup cols={2}>
  <Card title="Eventos de transação" icon="receipt" href="/webhooks/transacoes">
    O formato de `transaction.approved`, `failed` e `cancelled`.
  </Card>

  <Card title="Eventos de MED" icon="scale" href="/webhooks/med">
    O formato de `med.created` e `med.updated`.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.