Webhooks
Os webhooks avisam o seu sistema quando um cliente, um pagamento ou uma assinatura muda. São eles que devem liberar ou suspender o acesso de quem paga, não o retorno do checkout no navegador.
Configurando
Cadastre o webhook no dashboard, em Integrações → Webhooks, ou pela API (/v1/automations):
curl -X POST https://payments.cefis.com.br/api/v1/automations \
-H "Authorization: Bearer $NINEPAY_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"type": "webhook",
"name": "Meu sistema",
"webhook_url": "https://meusistema.com.br/webhooks/ninepay",
"triggers": ["payment.approved", "payment.updated", "subscription.updated"],
"status": "active"
}'O webhook_url precisa ser um endereço público (http ou https, fora de redes internas); senão a API responde 422. Use https.
Para conferir o endereço antes de esperar um evento de verdade, o formulário do webhook no dashboard tem um envio de teste para cada evento. São até 10 envios de teste por minuto; acima disso, a resposta é 429 e é só esperar um minuto. O limite vale também para o envio de teste pela API (POST /v1/automations/test).
Webhook com segredo só se edita pela tela
A API continua criando, listando e editando webhooks sem segredo, mas responde 422 em dois casos:
- Webhook gerido em Integrações, como um que já tem segredo (veja Validando): ele só se altera no dashboard, nem que seja só o nome ou o status. A API devolve o erro em
errors.webhook_urle o webhook fica como estava. - Evento não aceito em
triggers: a API devolve o erro emerrors.triggers.N, em queNé a posição do evento na lista, a partir de 0, e nada é gravado. Use os eventos da tabela de Eventos.
{
"message": "Evento inválido.",
"errors": {
"triggers.1": ["Evento inválido."]
}
}Eventos
| Evento | Quando |
|---|---|
customer.created | Um cliente foi criado |
customer.updated | Um cliente foi alterado |
payment.created | Uma cobrança ou fatura foi criada |
payment.updated | Um pagamento mudou (pago, recusado, cancelado, estornado, baixado...) |
payment.approved | Um pagamento foi aprovado |
subscription.created | Uma assinatura foi criada |
subscription.updated | Uma assinatura mudou (ativada, atrasada, pausada, reativada, cancelada...) |
all | Todos os eventos acima |
O que chega
Um POST com Content-Type: application/json:
{
"id": 987654,
"type": "payment.approved",
"data": {
"id": 1470491,
"uuid": "6f1c1a52-1d7e-4f43-a7a4-0a5a2b1c9e10",
"status": "paid",
"method": "credit",
"value": 600
},
"created_at": "2026-10-22 14:05:12"
}idé o do envio: o mesmo em todas as tentativas. Use para não processar o mesmo evento duas vezes.typeé o evento.dataé o recurso no mesmo formato da API (GET /customers/{id},GET /payments/{id}ouGET /subscriptions/{id}), sem o envelopedatada API.
Respondendo
- Responda com um status 2xx em até 10 segundos. Faça o processamento pesado depois de responder (numa fila, por exemplo).
- Qualquer outro status, ou a falta de resposta, conta como falha, e o envio é repetido: até 10 tentativas, com intervalos crescentes (1 s, 30 s, 1 min, 5 min, 10 min, 30 min e depois de hora em hora).
- Como pode chegar mais de uma vez, e fora de ordem, trate cada evento como um aviso para consultar o recurso, não como o estado final.
Validando
Cada envio de um webhook com segredo vem assinado, para você ter certeza de que foi a NinePay quem mandou. Dois headers acompanham o POST:
| Header | Valor |
|---|---|
X-Ninepay-Timestamp | O instante do envio, em segundos (Unix) |
X-Ninepay-Signature | sha256= seguido do HMAC-SHA256, em hexadecimal, de {timestamp}.{corpo} |
O HMAC usa o segredo do webhook (nep_whs_…) como chave, e o {corpo} é o corpo cru da requisição, exatamente como chegou, antes de qualquer parse do JSON. Se você reserializar o JSON, os bytes mudam e a conta não bate.
Para validar:
- Leia o corpo cru e os dois headers.
- Recuse se o timestamp tiver mais de 5 minutos de diferença para o seu relógio. Isso impede que alguém reenvie um envio capturado.
- Calcule
sha256=+ HMAC-SHA256 de"{timestamp}.{corpo}"com o segredo. - Compare com o header
X-Ninepay-Signatureem tempo constante (não use==). Se não for igual, responda 401.
A cada tentativa o timestamp é novo, então a assinatura muda de uma tentativa para outra, mesmo com o mesmo corpo. Valide cada uma.
// Node.js (Express): o corpo precisa chegar cru
// Monte esta rota antes de qualquer app.use(express.json()) global: senão o
// req.body chega já convertido em objeto e a conta não bate. Ou aplique o
// express.json() só nas outras rotas e deixe o express.raw só nesta.
const crypto = require('crypto');
app.post('/webhooks/ninepay', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.header('X-Ninepay-Timestamp') || '';
const signature = req.header('X-Ninepay-Signature') || '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.NINEPAY_WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body) // Buffer com o corpo cru
.digest('hex');
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// enfileire o processamento e responda logo
res.sendStatus(200);
});<?php
// PHP: use o corpo cru, não $_POST nem json_decode antes da conta
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_NINEPAY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_NINEPAY_SIGNATURE'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, getenv('NINEPAY_WEBHOOK_SECRET'));
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// enfileire o processamento e responda logo
http_response_code(200);O segredo
- O segredo aparece uma vez só: ao criar o webhook ou ao clicar em Gerar novo segredo. Copie e guarde num lugar seguro, como variável de ambiente.
- Ao gerar um novo segredo, o anterior para de valer na hora. Os envios seguintes já saem assinados com o novo, então atualize o seu sistema antes de gerar, ou valide os dois por um tempo.
- Webhooks criados antes da assinatura não têm segredo e seguem sem assinatura até alguém clicar em Gerar segredo. Sem os headers, não há o que validar.
A assinatura não substitui a consulta à API
Mesmo com a assinatura válida, antes de liberar algo, confirme o estado na API com o seu token: ao receber payment.approved para o pagamento 1470491, consulte GET /payments/1470491 e libere só se o status for paid.
Assim, um POST forjado ou repetido para o seu endereço não libera nada.
E-mail de erro
Se o seu endereço falha sem parar, a NinePay avisa por e-mail, para o e-mail cadastrado no webhook. O e-mail traz um link direto para os logs daquele webhook. O aviso sai quando o webhook acumula 10 falhas seguidas ou mais e está falhando há 6 horas ou mais.
- Sai no máximo 1 e-mail por dia por webhook.
- Sem e-mail cadastrado, não sai aviso. Os webhooks criados pela API (
/v1/automations) não têm e-mail: cadastre um no dashboard, em Integrações → Webhooks. - O webhook nunca é pausado: os eventos novos continuam saindo e voltam a chegar assim que o seu endereço responder 2xx. Um evento que esgotou as 10 tentativas, porém, não é reenviado. Para não perder nada, consulte na API o que mudou enquanto o endereço esteve fora.
Logs
Para ver o que a NinePay enviou e o que o seu servidor respondeu, abra o dashboard em Integrações → Logs de Webhooks. A página Integrações inteira é restrita ao dono da conta: quem não é o dono é redirecionado.
A lista mostra os envios dos webhooks da empresa nos últimos 14 dias. Os eventos de webhooks são armazenados por 14 dias; depois disso, os logs são apagados. Webhooks excluídos continuam com os logs visíveis dentro desse prazo.
Use os filtros para achar o envio que procura:
- Evento e Webhook.
- Status: Todos, Qualquer erro ou um dos códigos 200, 400, 401, 404, 500, 502, 503 e 504.
- Período: Hoje, Últimos 7 dias, Últimos 14 dias ou Personalizado, sempre dentro dos 14 dias.
Ao clicar num envio, os Detalhes do log mostram a URL, o evento, a data, a situação e o código, a duração, o número de tentativas, o payload enviado e o corpo da resposta do seu servidor.
- A tela não reenvia eventos. Um evento que esgotou as tentativas não volta a sair; consulte na API o que mudou.