> ## Documentation Index
> Fetch the complete documentation index at: https://docs.purin.cash/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.

## Onde fica o secret

Cada loja tem o seu, em [Dashboard → Equipe & API → Developer
API](https://purin.cash/dashboard/api). Guarde numa variável de ambiente do seu
backend.

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

### Um secret por URL

Na mesma tela, cada URL da conta pode ter o secret dela. Deixe vazio e ela é assinada com
o secret da conta, que é como toda URL funciona por padrão: quem já tinha URLs
configuradas não precisa mexer em nada.

Vale a pena separar quando as URLs vão para sistemas diferentes. Um secret por destino
significa que o que vazar no sistema de BI não assina evento para o backend de produção.

> [!NOTE]
>
> O `callbackUrl` que você manda ao criar uma cobrança não está na lista da conta, então
> ele é sempre assinado com o secret da conta.

## 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.

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

## 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.

## Erros comuns

> [accordion] A assinatura nunca bate
>
> 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] Funciona local e falha em produção
>
> 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] Bate às vezes
>
> Você está comparando a assinatura contra um corpo re-serializado. Guarde os bytes
> originais em vez de reconstruir o JSON.


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