# Webhooks

> Eventos enviados ao seu servidor, assinatura HMAC, retentativas, quando chegam e conversão de campanha pelo servidor.

Fonte: https://app.thronecorporate.com/docs/webhooks

## Cadastrar o endereço

No painel, em [Webhooks](https://app.thronecorporate.com/painel/webhook-settings), adicione a URL do seu servidor. Você pode ter até 20 endereços, cada um com:

- a própria URL, sempre em **HTTPS**;
- a lista de eventos que quer receber (vazia = todos);
- o próprio **secret de assinatura**, que você copia no painel e guarda no servidor.

O botão de teste envia um evento `test.ping` na hora, assinado como os outros:

```json
{ "event_id": "...", "type": "test.ping", "version": "2026-05-01", "created_at": "2026-10-03T14:00:00Z",
  "data": { "message": "Webhook de teste do Throne, seu endpoint está funcionando!", "sent_at": "2026-10-03T14:00:00Z" } }
```

O histórico de entregas fica em [Entregas de webhook](https://app.thronecorporate.com/painel/webhook-deliveries).

## Eventos

| Evento | Quando |
|---|---|
| `transaction.paid` | Pagamento confirmado: Pix, cartão (Sterling, Imperium, NovaCorp, Eminence, Apex, ShadowBank, Vortex) e Internacional (Paycore) |
| `transaction.in_review` | **Só na Vortex.** O banco autorizou e a adquirente está analisando. O cartão já foi debitado, e a venda continua `pending` |
| `withdrawal.completed` | Saque pago |
| `withdrawal.failed` | Saque não pago (o valor volta para o saldo) |
| `test.ping` | Botão de teste do painel |

Só esses eventos existem. Recusa de cartão, expiração, estorno e contestação **não geram webhook**: acompanhe pelo `GET /transactions/{id}`.

O `transaction.in_review` **vem desmarcado**: marque-o no endpoint em
[Webhooks](https://app.thronecorporate.com/painel/webhooks) para começar a
receber. Marcar ou desmarcar não muda nada nos outros eventos.

Recorrência não muda nada aqui: cada cobrança de ciclo chega como um `transaction.paid` normal, com o mesmo formato e com `transaction_id` próprio, como qualquer venda de cartão. Para ligar a cobrança à assinatura, consulte a recorrência, como em [Recorrência](/docs/cartao/recorrencias).

### `transaction.in_review`

Só na **Vortex**. Nessa adquirente o banco autoriza na hora e a análise de
risco da adquirente libera depois. Nesse intervalo **o cartão do comprador já
foi debitado** e a venda continua `pending` para você.

O corpo tem os mesmos campos do `transaction.paid`, com os valores que uma
venda pendente tem: `amount_cents`, `fee_cents` e `net_amount_cents` só
nascem na liquidação, então chegam em zero, e `status` vem `pending`. **Não
existe status novo**: quem diz "em análise" é o nome do evento.

```json
{
  "type": "transaction.in_review",
  "data": {
    "transaction_id": "9f2c1d7e-4a8b-4c3d-8e9f-1a2b3c4d5e6f",
    "status": "pending",
    "payment_method": "credit_card",
    "amount_cents": 0,
    "fee_cents": 0,
    "net_amount_cents": 0,
    "card_brand": "visa",
    "card_last4": "4242",
    "currency": "BRL",
    "native_amount_cents": 49900,
    "customer": { "name": "Ana Lima", "email": "ana@exemplo.com" },
    "metadata": { "order_id": "ORD-2026-00001" },
    "created_at": "2026-10-29T18:04:11Z"
  }
}
```

**Não libere acesso neste evento.** Ele diz que o cartão foi debitado, não que
a venda foi aprovada: a análise pode recusar, e aí a venda vira `failed` e o
valor volta ao comprador. Quem libera é o `transaction.paid`.

Cada venda avisa **uma vez**. Reenvio da adquirente e releitura do seu lado não
repetem o aviso.

Depois dele, três caminhos: a análise aprova e chega o `transaction.paid`; a
análise recusa e a venda vira `failed` (sem webhook, como as outras recusas);
ou a análise demora, e a venda segue `pending` até resolver.

## Quando o webhook chega

O `transaction.paid` sai quando o pagamento é confirmado. O tempo até ele chegar depende do meio de pagamento:

| Meio | Quando chega |
|---|---|
| Pix | Logo depois da confirmação do pagamento |
| Cartão na Sterling e na Imperium | Logo depois da aprovação, na maior parte das vendas. Uma venda em análise antifraude só gera o webhook quando a análise aprova |
| Cartão na NovaCorp | Logo depois da aprovação quando o comprador volta da tela do banco. Se ele sair antes, a confirmação vem pela conferência automática, alguns minutos depois |
| Cartão na Eminence | Logo depois da aprovação. Quando o banco pede verificação, depois que o comprador a conclui. Uma venda em análise antifraude só gera o webhook quando a análise aprova |
| Cartão na Apex | Logo depois da aprovação. Uma venda em análise antifraude só gera o webhook quando a análise aprova |
| Cartão na ShadowBank | Logo depois da aprovação. Uma venda em análise antifraude só gera o webhook quando a análise aprova |
| Cartão na Vortex | Logo depois da aprovação. Uma venda em análise antifraude só gera o webhook quando a análise aprova |
| Internacional (Paycore) | Logo depois da aprovação |

Não cancele um pedido por falta de webhook antes da validade da cobrança (24 horas no cartão). Na dúvida, consulte `GET /transactions/{id}`.

## Conversão de campanha pelo servidor

Se você mede vendas de anúncio (pixel de compra), envie a conversão **pelo seu servidor**, ao receber `transaction.paid`, pela API de conversões da plataforma de anúncios. Não dependa só do evento `throne-elements:paid` da página:

- na NovaCorp, quando o comprador vem de um navegador de app (Instagram, Facebook, Google, TikTok), a aba sai da sua página para pagar e nenhum evento de tela chega;
- o comprador pode fechar a página antes do evento;
- bloqueadores de anúncio derrubam pixels no navegador.

Para não contar a mesma compra duas vezes quando o pixel da página também disparar, use o `transaction_id` como identificador do evento nos dois lados. Assim a plataforma de anúncios junta os dois envios num só.

## Formato

Todo evento chega como `POST` com corpo JSON:

```json
{
  "event_id": "8c1f3c5e-2c1a-4d1e-9a2b-1f0c9d7e6b5a",
  "type": "transaction.paid",
  "version": "2026-05-01",
  "created_at": "2026-10-03T14:21:07Z",
  "data": { }
}
```

### `transaction.paid`

Pix:

```json
"data": {
  "transaction_id": "72394240-db89-4ec5-b370-4a3e7b998409",
  "status": "paid",
  "payment_method": "pix",
  "amount_cents": 4990,
  "fee_cents": 250,
  "net_amount_cents": 4740,
  "paid_at": "2026-10-03T14:21:05Z",
  "customer": { "name": "Maria Souza", "document": "12345678909", "email": "maria@exemplo.com" },
  "shipping_address": null,
  "items": null,
  "metadata": { "loja_pedido_id": "PED-1042" }
}
```

Cartão e Internacional trazem também `card_brand`, `card_last4`, `currency`, `native_amount_cents` e `fx_rate`:

```json
"data": {
  "transaction_id": "ae3bf1da-0c9b-4818-9140-c6432152c23e",
  "status": "paid",
  "payment_method": "credit_card",
  "amount_cents": 31237,
  "fee_cents": 4148,
  "net_amount_cents": 27089,
  "paid_at": "2026-10-03T14:30:58Z",
  "card_brand": "visa",
  "card_last4": "4242",
  "currency": "CHF",
  "native_amount_cents": 4990,
  "fx_rate": "6.26000000",
  "customer": { "name": "Luca Meier", "email": "luca@example.ch" },
  "shipping_address": null,
  "items": null,
  "metadata": {}
}
```

`fx_rate` chega como texto com 8 casas. Trate os campos que não usa (ou que vierem `null`) como opcionais: qualquer `transaction.paid` pode trazer o conjunto completo.

### `withdrawal.completed` e `withdrawal.failed`

```json
"data": { "withdrawal_id": "0b8f3a52-6c1d-4e2f-9a7b-3c4d5e6f7a8b", "amount_cents": 50000, "completed_at": "2026-10-03T14:45:12Z" }
```

```json
"data": { "withdrawal_id": "0b8f3a52-6c1d-4e2f-9a7b-3c4d5e6f7a8b", "amount_cents": 50000, "reason": "Chave Pix inexistente" }
```

O `reason` de `withdrawal.failed` pode vir técnico. Para mostrar ao seu cliente, use o `failure_reason` do `GET /withdrawals/{id}`, que já vem em linguagem amigável.

## Validar a assinatura

Cada entrega traz o header `X-Throne-Signature`: o HMAC-SHA256, em hexadecimal, do **corpo cru** da requisição, com o secret do endereço. Calcule sobre os bytes exatamente como chegaram, antes de interpretar o JSON (reformatar o JSON muda a assinatura).

```php PHP
$body = file_get_contents('php://input');
$recebida = $_SERVER['HTTP_X_THRONE_SIGNATURE'] ?? '';
$esperada = hash_hmac('sha256', $body, getenv('THRONE_WEBHOOK_SECRET'));

if (! hash_equals($esperada, $recebida)) {
    http_response_code(401);
    exit;
}

$evento = json_decode($body, true);
http_response_code(200); // responda rápido; processe em segundo plano
```
```js Node.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// corpo cru: não use express.json() nesta rota
app.post('/throne/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const recebida = req.get('X-Throne-Signature') || '';
  const esperada = crypto
    .createHmac('sha256', process.env.THRONE_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  const ok = recebida.length === esperada.length
    && crypto.timingSafeEqual(Buffer.from(recebida), Buffer.from(esperada));
  if (!ok) return res.sendStatus(401);

  const evento = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // responda rápido; processe em segundo plano
});
```
```python Python
import hashlib, hmac, json, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/throne/webhook")
def throne_webhook():
    body = request.get_data()  # bytes crus
    esperada = hmac.new(os.environ["THRONE_WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(esperada, request.headers.get("X-Throne-Signature", "")):
        abort(401)
    evento = json.loads(body)
    return "", 200  # responda rápido; processe em segundo plano
```
```go Go
func throneWebhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, []byte(os.Getenv("THRONE_WEBHOOK_SECRET")))
	mac.Write(body)
	esperada := hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(esperada), []byte(r.Header.Get("X-Throne-Signature"))) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	w.WriteHeader(http.StatusOK) // responda rápido; processe em segundo plano
}
```

## Entrega e retentativas

- **Responda 2xx em até 3 segundos.** Grave o evento e processe depois; processamento pesado dentro da requisição faz a entrega falhar por tempo.
- Sem 2xx, o Throne tenta de novo: **até 10 tentativas no total** (a primeira e mais 9), com intervalos crescentes, de segundos até algumas horas.
- Responda 2xx também aos tipos que você não trata (como `test.ping`), para não gerar retentativas.
- Use a URL final. A entrega não segue redirecionamento como POST: uma URL que redireciona (por exemplo, de `http` para `https`, ou com e sem `www`) faz a entrega falhar.
- Endereços internos (como `localhost` ou `192.168.x.x`) não recebem entregas.

## Não processe duas vezes

O mesmo pagamento pode chegar mais de uma vez (retentativas, ou mais de um endereço cadastrado). O `event_id` é diferente para cada endereço, então **deduplique pelo conteúdo**:

- `transaction.paid`: pela dupla `type` + `data.transaction_id`;
- saques: por `type` + `data.withdrawal_id`.

Se o evento já foi processado, responda `200` de novo sem refazer nada.

## Quando não chega webhook

Recusa e expiração de cartão, expiração de Pix, estornos e contestações não geram evento, e alguns saques terminam sem webhook. Para esses casos, consulte:

- `GET /transactions/{id}` para cobranças e checkouts;
- `GET /withdrawals/{id}` para saques.
