---
agentTools:
  projectIndex: https://docs.ninepay.com.br/llms.txt
  projectSearch: https://docs.ninepay.com.br/agentSearch?query=term1%20term2
---

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

```bash
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](#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](#eventos).
>
> ```json
> {
>   "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`:

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

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

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](#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.
