Acompanhar
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
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" }'
{ "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 |
|---|---|---|
urlobrigatório | string | URL que vai receber os eventos. Precisa ser HTTPS e responder em até 8 segundos. |
eventsopcional | 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
{ "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:
- Leia o corpo cru da requisição — a string exata, antes de qualquer parse de JSON.
- Extraia
tev1do headerNovex-Signature. - Calcule o HMAC de
t + "." + corpoe compare comv1em tempo constante.
<?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';
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); } );
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
==. Usehash_equals,timingSafeEqualoucompare_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:
// 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
Veja o que enviamos, quando, e o que o seu servidor respondeu:
{ "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
# 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.