# Webhooks

Receber os eventos em tempo real e validar a assinatura.

Webhook é como você fica sabendo de um pagamento no instante em que ele acontece. Você cadastra uma URL, nós enviamos um `POST` assinado a cada evento — sem você precisar perguntar nada.

## Cadastrar o endpoint

`POST /v1/webhooks`

_Requisição_

```bash
curl -X POST https://novexfinance.com.br/api/v1/webhooks \
  -H "Authorization: Bearer $NOVEX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://sualoja.com.br/webhooks/novex",
    "events": "all"
  }'
```

_Resposta 201_

```json
{
  "object": "webhook_endpoint",
  "id": "whe_12",
  "url": "https://sualoja.com.br/webhooks/novex",
  "events": "all",
  "active": true,
  "secret": "whsec_6a1f92c04b7e35d8fa1c60b394e27d5081af6c3b92e4d70a",
  "created_at": "2026-08-07T14:32:10-03:00"
}
```

> 🚫 **Guarde o `secret` agora.** Ele aparece uma única vez, é o que valida a assinatura de cada evento, e não temos como reexibi-lo depois. Se perder, remova o endpoint e cadastre outro.

| Campo | Tipo | Descrição |
| --- | --- | --- |
| `url` obrigatório | string | URL que vai receber os eventos. Precisa ser **HTTPS** e responder em até 8 segundos. |
| `events` opcional | string | `"all"` (padrão), `"transaction"` (só cobranças) ou `"withdrawal"` (só saques). |

Você pode cadastrar até **10 endpoints**. URLs repetidas são recusadas com `409`.

## Eventos

| Evento | Quando dispara | Ação típica |
| --- | --- | --- |
| `charge.paid` | Pagamento confirmado. | **Liberar o pedido.** |
| `charge.refused` | Cartão não autorizado pelo emissor. | Avisar o cliente e oferecer outro meio. |
| `charge.failed` | A cobrança não pôde ser concluída. | Cancelar o pedido. |
| `charge.refunded` | Valor devolvido ao pagador. | Reverter a liberação. |
| `charge.chargeback` | Contestação aceita; valor debitado do saldo. | Reverter a liberação e registrar a perda. |
| `charge.disputed` | O portador abriu contestação. | Separar comprovantes de entrega. |
| `charge.authorized` | Cartão autorizado, ainda não capturado. | Informativo. |
| `charge.processing` | Pagamento em processamento. | Informativo. |
| `withdrawal.paid` | Saque concluído na conta de destino. | Baixar no seu financeiro. |
| `withdrawal.failed` | Saque falhou; valor volta ao saldo. | Verificar a chave PIX. |
| `withdrawal.rejected` | Saque recusado. | Verificar a chave PIX. |

> ℹ️ Não enviamos evento na **criação** da cobrança — você já recebeu esses dados na resposta do `POST`. O primeiro webhook chega quando o status muda.

## Formato do evento

_Corpo do POST_

```json
{
  "id": "evt_ac41f9b26d80375e1c4a",
  "object": "event",
  "type": "charge.paid",
  "created_at": "2026-08-07T14:35:02-03:00",
  "data": {
    "object": "charge",
    "id": "ch_9f2a71c4e8b35d06a147",
    "status": "paid",
    "amount": 12990,
    "currency": "BRL",
    "payment_method": "pix",
    "description": "Pedido #48219",
    "reference": "48219",
    "customer": {
      "name": "Maria Oliveira",
      "email": "maria@exemplo.com.br",
      "document": "39053344705"
    },
    "fee_amount": 129,
    "net_amount": 12861,
    "available_at": "2026-08-07T14:35:02-03:00",
    "paid_at": "2026-08-07T14:35:02-03:00",
    "metadata": {
      "canal": "app-ios"
    }
  }
}
```

O objeto dentro de `data` é exatamente o mesmo que você recebe consultando a cobrança pela API.

### Headers enviados

| Header | Conteúdo |
| --- | --- |
| `Novex-Signature` | `t=<unix>,v1=<hmac-sha256>` — a assinatura. |
| `Novex-Event-Id` | ID único do evento. Use para não processar duas vezes. |
| `Novex-Event-Type` | Tipo do evento, ex.: `charge.paid`. |
| `Novex-Delivery-Attempt` | Número da tentativa, a partir de 1. |

## Validar a assinatura

> 🚫 **Valide sempre, antes de olhar o conteúdo.** A sua URL de webhook é pública: qualquer um pode enviar um POST dizendo que um pedido foi pago. Sem validação, você entrega produto de graça para quem descobrir o endereço.

A assinatura é um HMAC-SHA256 sobre `<timestamp>.<corpo cru>`, com o `secret` do endpoint como chave. Três passos:

1. Leia o **corpo cru** da requisição — a string exata, antes de qualquer parse de JSON.
2. Extraia `t` e `v1` do header `Novex-Signature`.
3. Calcule o HMAC de `t + "." + corpo` e compare com `v1` em **tempo constante**.

_Validação completa_

```php
<?php

$secret = getenv('NOVEX_WEBHOOK_SECRET');
$payload = file_get_contents('php://input'); // corpo CRU
$header = $_SERVER['HTTP_NOVEX_SIGNATURE'] ?? '';

// t=1786... ,v1=abc...
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$signature = (string) ($parts['v1'] ?? '');

// 1. janela de 5 minutos: barra reenvio de um evento capturado
if (abs(time() - $timestamp) > 300) {
    http_response_code(400);
    exit;
}

// 2. hash_equals: comparação em tempo constante
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

// 3. assinatura válida — agora sim dá para confiar no conteúdo
$event = json_decode($payload, true);

if ($event['type'] === 'charge.paid') {
    $pedido = $event['data']['reference'];
    liberarPedido($pedido, $event['id']);
}

http_response_code(200);
echo 'ok';
```

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// express.raw: o corpo CRU é obrigatório. JSON re-serializado
// muda espaços e ordem de chaves, e a assinatura não bate mais.
app.post('/webhooks/novex',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const payload = req.body.toString('utf8');
    const header = req.get('Novex-Signature') || '';

    const parts = Object.fromEntries(
      header.split(',').map((p) => p.split('='))
    );

    // 1. janela de 5 minutos
    const age = Math.abs(Date.now() / 1000 - Number(parts.t));
    if (age > 300) return res.sendStatus(400);

    // 2. comparação em tempo constante
    const expected = crypto
      .createHmac('sha256', process.env.NOVEX_WEBHOOK_SECRET)
      .update(`${parts.t}.${payload}`)
      .digest('hex');

    const a = Buffer.from(expected);
    const b = Buffer.from(parts.v1 || '');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(401);
    }

    // 3. conteúdo confiável
    const event = JSON.parse(payload);

    if (event.type === 'charge.paid') {
      liberarPedido(event.data.reference, event.id);
    }

    res.sendStatus(200);
  }
);
```

```python
import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ['NOVEX_WEBHOOK_SECRET'].encode()

@app.route('/webhooks/novex', methods=['POST'])
def novex_webhook():
    payload = request.get_data(as_text=True)  # corpo CRU
    header = request.headers.get('Novex-Signature', '')

    parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)

    # 1. janela de 5 minutos
    if abs(time.time() - int(parts.get('t', 0))) > 300:
        abort(400)

    # 2. compare_digest: tempo constante
    expected = hmac.new(
        SECRET,
        f'{parts["t"]}.{payload}'.encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, parts.get('v1', '')):
        abort(401)

    # 3. conteúdo confiável
    event = json.loads(payload)

    if event['type'] == 'charge.paid':
        liberar_pedido(event['data']['reference'], event['id'])

    return '', 200
```

> ⚠️ **Três detalhes que costumam quebrar a validação:**
> 
> - Usar o JSON já parseado e re-serializado em vez do **corpo cru**. Espaçamento e ordem de chaves mudam, e o hash não bate.
> - Comparar com `==`. Use `hash_equals`, `timingSafeEqual` ou `compare_digest` — comparação comum vaza informação pelo tempo de execução.
> - Esquecer de conferir o `t`. Sem a janela de tempo, uma cópia capturada do evento pode ser reenviada meses depois com assinatura ainda válida.

## Como responder

Responda **`200`** assim que gravar o evento. Qualquer status fora da faixa 2xx conta como falha e entra na fila de reenvio.

**Processe depois de responder.** Se você emite nota fiscal, envia e-mail e atualiza o estoque antes de responder, o webhook estoura os **8 segundos** de timeout, e nós reenviamos — mesmo que tudo tenha dado certo do seu lado. Grave o evento, responda 200, processe em fila.

### Idempotência do seu lado

Um evento pode chegar mais de uma vez: falha de rede, reenvio, timeout. Guarde o `event.id` e ignore o que já processou:

_Evitar processar duas vezes_

```php
// coluna UNIQUE em event_id resolve a corrida entre dois
// webhooks simultâneos melhor do que um SELECT antes do INSERT
try {
    $db->prepare('INSERT INTO eventos_novex (event_id, tipo) VALUES (?, ?)')
       ->execute([$event['id'], $event['type']]);
} catch (PDOException $e) {
    if ((int) $e->errorInfo[1] === 1062) {
        http_response_code(200); // já processado
        exit;
    }
    throw $e;
}

processar($event);
```

## Reenvio automático

Se a entrega falhar, tentamos de novo com intervalos crescentes — até **6 tentativas** ao longo de aproximadamente 30 horas:

| Tentativa | Quando |
| --- | --- |
| 1ª | Imediatamente |
| 2ª | ~1 minuto depois |
| 3ª | ~5 minutos depois |
| 4ª | ~30 minutos depois |
| 5ª | ~2 horas depois |
| 6ª | ~6 horas depois |

Após **20 falhas seguidas**, o endpoint é desativado — continuar tentando numa URL morta só atrasaria os eventos dos endpoints que funcionam. Para reativar, cadastre-o novamente.

## Depurar entregas

`GET /v1/webhook-deliveries`

Veja o que enviamos, quando, e o que o seu servidor respondeu:

_Resposta 200_

```json
{
  "object": "list",
  "data": [
    {
      "object": "webhook_delivery",
      "event_id": "evt_ac41f9b26d80375e1c4a",
      "event": "charge.paid",
      "object_type": "charge",
      "object_id": "ch_9f2a71c4e8b35d06a147",
      "url": "https://sualoja.com.br/webhooks/novex",
      "attempt": 1,
      "response_code": 500,
      "delivered": false,
      "next_retry_at": "2026-08-07T14:36:02-03:00",
      "created_at": "2026-08-07T14:35:02-03:00"
    }
  ],
  "has_more": false
}
```

## Listar e remover endpoints

`GET /v1/webhooks`

`DELETE /v1/webhooks/{id}`

_Requisições_

```bash
# listar (o secret não é devolvido aqui)
curl https://novexfinance.com.br/api/v1/webhooks \
  -H "Authorization: Bearer $NOVEX_API_KEY"

# remover
curl -X DELETE https://novexfinance.com.br/api/v1/webhooks/whe_12 \
  -H "Authorization: Bearer $NOVEX_API_KEY"
```

A listagem mostra `consecutive_failures` e `last_status_code` — bons indicadores de que o seu endpoint parou de responder.


---

Documentação completa: https://novexfinance.com.br/docs/webhooks
Base da API: https://novexfinance.com.br/api/v1
