Pular para o conteúdo

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):

Shell
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_url e o webhook fica como estava.
  • Evento não aceito em triggers: a API devolve o erro em errors.triggers.N, em que N é a posição do evento na lista, a partir de 0, e nada é gravado. Use os eventos da tabela de Eventos.
JSON
{
  "message": "Evento inválido.",
  "errors": {
    "triggers.1": ["Evento inválido."]
  }
}

Eventos

EventoQuando
customer.createdUm cliente foi criado
customer.updatedUm cliente foi alterado
payment.createdUma cobrança ou fatura foi criada
payment.updatedUm pagamento mudou (pago, recusado, cancelado, estornado, baixado...)
payment.approvedUm pagamento foi aprovado
subscription.createdUma assinatura foi criada
subscription.updatedUma assinatura mudou (ativada, atrasada, pausada, reativada, cancelada...)
allTodos os eventos acima

O que chega

Um POST com Content-Type: application/json:

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} ou GET /subscriptions/{id}), sem o envelope data da 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:

HeaderValor
X-Ninepay-TimestampO instante do envio, em segundos (Unix)
X-Ninepay-Signaturesha256= 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:

  1. Leia o corpo cru e os dois headers.
  2. 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.
  3. Calcule sha256= + HMAC-SHA256 de "{timestamp}.{corpo}" com o segredo.
  4. Compare com o header X-Ninepay-Signature em 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.

JavaScript
// 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
// 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.