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

# Validando a assinatura

> Sem isso, qualquer pessoa na internet consegue dizer que pagou.

Todo webhook chega assinado com HMAC-SHA256 no header `X-Webhook-Signature`. Validar essa
assinatura é o que separa "recebi uma notificação" de "recebi uma notificação nossa".

<Warning>
  A sua `callbackUrl` é pública. Sem validação, um `curl` com `{"status":"paid"}` é
  suficiente para alguém levar o seu produto sem pagar.
</Warning>

## Onde fica o secret

Cada loja tem o seu, em [Dashboard → API e Desenvolvedores →
Webhooks](https://purincash.com/dashboard/api). Guarde numa variável de ambiente do seu
backend.

Se vazar, regenere pelo painel. O antigo para de funcionar na hora.

## Como calcular

O HMAC é sobre o **corpo cru** da requisição, a string exata que chegou, antes de qualquer
`JSON.parse`. Se você parsear e re-serializar, a ordem das chaves ou o espaçamento mudam e
a assinatura nunca vai bater.

Compare com `timingSafeEqual` ou equivalente. Comparação com `===` vaza informação pelo
tempo de execução.

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

  const app = express();

  // express.raw, não express.json: precisamos dos bytes originais.
  app.post("/webhooks/purincash", express.raw({ type: "application/json" }), (req, res) => {
    const assinatura = req.header("X-Webhook-Signature") || "";
    const esperado = crypto
      .createHmac("sha256", process.env.PURINCASH_WEBHOOK_SECRET)
      .update(req.body)
      .digest("hex");

    const valida =
      assinatura.length === esperado.length &&
      crypto.timingSafeEqual(Buffer.from(assinatura), Buffer.from(esperado));

    if (!valida) return res.status(401).send("assinatura inválida");

    const evento = JSON.parse(req.body.toString());
    const id = req.header("X-Webhook-Id");

    // Grave, responda, processe depois.
    enfileirar({ id, evento });
    res.json({ ok: true });
  });
  ```

  ```php PHP theme={null}
  <?php
  $corpo      = file_get_contents('php://input'); // corpo cru
  $assinatura = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
  $secret     = getenv('PURINCASH_WEBHOOK_SECRET');
  $esperado   = hash_hmac('sha256', $corpo, $secret);

  if (!hash_equals($esperado, $assinatura)) {
    http_response_code(401);
    exit('assinatura inválida');
  }

  $evento = json_decode($corpo, true);
  $id     = $_SERVER['HTTP_X_WEBHOOK_ID'] ?? '';

  enfileirar($id, $evento);

  http_response_code(200);
  echo json_encode(['ok' => true]);
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, os, json
  from flask import Flask, request, abort

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

  @app.post("/webhooks/purincash")
  def webhook():
      corpo = request.get_data()  # bytes crus
      assinatura = request.headers.get("X-Webhook-Signature", "")
      esperado = hmac.new(SECRET, corpo, hashlib.sha256).hexdigest()

      if not hmac.compare_digest(esperado, assinatura):
          abort(401)

      evento = json.loads(corpo)
      enfileirar(request.headers.get("X-Webhook-Id"), evento)
      return {"ok": True}
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "io"
      "net/http"
      "os"
  )

  func webhook(w http.ResponseWriter, r *http.Request) {
      corpo, _ := io.ReadAll(r.Body)

      mac := hmac.New(sha256.New, []byte(os.Getenv("PURINCASH_WEBHOOK_SECRET")))
      mac.Write(corpo)
      esperado := hex.EncodeToString(mac.Sum(nil))

      if !hmac.Equal([]byte(r.Header.Get("X-Webhook-Signature")), []byte(esperado)) {
          http.Error(w, "assinatura inválida", http.StatusUnauthorized)
          return
      }

      var evento map[string]any
      json.Unmarshal(corpo, &evento)

      enfileirar(r.Header.Get("X-Webhook-Id"), evento)
      w.WriteHeader(http.StatusOK)
  }
  ```
</CodeGroup>

## Idempotência

O header `X-Webhook-Id` vem no formato `evento:id`, como `payment.paid:psa_abc123`.
Reentrega do mesmo evento usa o **mesmo** valor.

```js theme={null}
async function processar(webhookId, evento) {
  // Chave única no banco: a segunda inserção falha e você sai fora.
  const novo = await db.webhooksProcessados.insertIfAbsent(webhookId);
  if (!novo) return; // já tratamos, nada a fazer

  await entregarProduto(evento);
}
```

<Warning>
  Deduplique **antes** de creditar saldo, enviar licença ou disparar e-mail. Depois de
  entregar não tem como voltar atrás, e a reentrega não é hipótese remota: é o
  comportamento normal quando o seu servidor demora mais de 5 segundos para responder.
</Warning>

## Erros comuns

<AccordionGroup>
  <Accordion title="A assinatura nunca bate" icon="circle-xmark">
    Quase sempre é middleware de JSON rodando antes do handler e consumindo o corpo. Em
    Express, `express.json()` global quebra a validação. Registre a rota do webhook com
    `express.raw()` antes do parser global.
  </Accordion>

  <Accordion title="Funciona local e falha em produção" icon="cloud">
    Proxy ou CDN reescrevendo o corpo. Verifique se não há compressão, reformatação de JSON
    ou WAF alterando a requisição no caminho.
  </Accordion>

  <Accordion title="Bate às vezes" icon="dice">
    Você está comparando a assinatura contra um corpo re-serializado. Guarde os bytes
    originais em vez de reconstruir o JSON.
  </Accordion>
</AccordionGroup>
