# Webhooks

A ValidaPay envia um `POST` JSON para a URL cadastrada sempre que um evento ocorre. Cadastre em `https://app.validapay.com.br/integracao/webhooks`.

## Regras de recebimento

- **Responda em menos de 5 segundos.** Esse é o timeout do envio: passou disso, a entrega é abortada e registrada como falha. Não processe de forma síncrona dentro do handler; enfileire e processe depois.
- Qualquer resposta **2xx** conta como sucesso, não só `200`.
- Trate o recebimento como **idempotente** — o mesmo evento pode chegar mais de uma vez.
- Não confie na ordem de chegada. Use `timestamp` para ordenar.
- Valide a assinatura antes de confiar no payload.

### Reentrega

**Não há retry automático.** Uma entrega que falha (timeout, erro de rede, resposta fora da faixa 2xx) fica registrada como `failed` e não é reenviada sozinha.

O reenvio é manual, a partir do histórico de entregas:

```http
POST /v1/users/webhooks/{webhookEventId}/retry
Authorization: Bearer {token}
```

O reenvio usa o mesmo corpo da tentativa original, com uma assinatura nova (o `t` da assinatura muda). Se o seu endpoint pode ficar indisponível, projete a conciliação para não depender só do webhook: consulte a API pelo `chargeId` ou `subscriptionId` quando perceber lacunas.

## Segurança: verificando a assinatura

Todo webhook chega com o header `X-Webhook-Signature`, que **não** é um HMAC simples do corpo. Ele é composto:

```
X-Webhook-Signature: t=1757083200000,v1=9f2b8c...
```

- `t` — timestamp em milissegundos
- `v1` — HMAC SHA-256 em hexadecimal

A string assinada é o timestamp, um ponto, e o corpo:

```
v1 = HMAC_SHA256(secret, `${t}.${corpo_bruto}`)
```

**Use o corpo bruto, byte a byte.** Se você fizer `JSON.parse` e reserializar, o espaçamento e a ordem das chaves mudam e o HMAC deixa de bater. Em Express, isso significa capturar o raw body antes do `express.json()`:

```javascript
import crypto from 'crypto';
import express from 'express';

const app = express();

app.post('/webhooks/validapay',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.get('X-Webhook-Signature') ?? '';
    const { t, v1 } = Object.fromEntries(
      header.split(',').map((part) => part.split('='))
    );

    const esperado = crypto
      .createHmac('sha256', process.env.VALIDAPAY_WEBHOOK_SECRET)
      .update(`${t}.${req.body.toString('utf8')}`)
      .digest('hex');

    const confere =
      v1?.length === esperado.length &&
      crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(esperado));

    if (!confere) return res.sendStatus(401);

    const idadeMs = Date.now() - Number(t);
    if (idadeMs > 5 * 60 * 1000) return res.sendStatus(401);

    res.sendStatus(200);
    processarDepois(JSON.parse(req.body.toString('utf8')));
  }
);
```

Compare com `timingSafeEqual`, não com `===`. E rejeite assinaturas antigas — sem janela de tolerância, uma assinatura capturada vale para sempre.

| Header | Conteúdo |
|---|---|
| `X-Webhook-Signature` | `t=<timestamp>,v1=<hmac hex>` |
| `x-access-token` | Token estático opcional, se configurado no cadastro |

## Envelope comum

Todo evento traz ao menos:

```json
{
  "event": "payment.success",
  "timestamp": "2026-09-05T12:00:00.000Z",
  "accountId": "4231833"
}
```

**Atenção à chave da conta.** Ela muda conforme o evento:

| Chave | Eventos |
|---|---|
| `accountNumber` | `subscription.*`, `charge.created` e o `payment.success` de cobranças com `subscriptionId` |
| `accountId` | `payment.failed`, `refund.*`, `onboarding.*`, `med.*` e o `payment.success` de cobrança avulsa via Pix direto |

Na prática, leia os dois: `payload.accountNumber ?? payload.accountId`. A regra é que os eventos enriquecidos com dados da assinatura usam `accountNumber`; os demais, `accountId`.

Eventos de assinatura, pagamento recorrente e `charge.created` acrescentam a base de assinatura (`subscriptionId`, `customerId`, `items`, `currentCycle`, `customer`). O `payment.success` de uma **cobrança avulsa** é diferente:

```json
{
  "event": "payment.success",
  "timestamp": "2026-09-05T12:00:00.000Z",
  "accountId": "4231833",
  "chargeId": "cha_1771511282731_9p1wo3tql",
  "amount": 10.00,
  "paymentMethod": "PIX",
  "paymentId": "E00360305202603261323369f2b7a1c4",
  "paidAt": "2026-09-05T12:00:00.000Z",
  "metadata": { "orderId": "pedido-1001" },
  "payer": {
    "name": "João da Silva",
    "taxId": "12345678901",
    "bank": "13935893",
    "account": "410900056",
    "branch": "0001",
    "accountType": "CACC"
  }
}
```

`payer.accountType` vem no padrão do SPI: `CACC` (corrente), `SVGS` (poupança) ou `TRAN` (conta de pagamento). `metadata` chega `null` quando a cobrança foi criada sem metadados.

### Correlacionando com o seu pedido

O mecanismo **depende da rota** usada para criar a cobrança:

| Rota de criação | Como correlacionar |
|---|---|
| `POST /v1/charges/pix` | `metadata` — é persistido e volta no webhook |
| `POST /v1/charges` | `externalId` — `metadata` é aceito mas **não** é gravado |

```javascript
// em /v1/charges/pix
{ "amount": 10.00, "metadata": { "orderId": "pedido-1001" } }
// volta em payment.success:
{ "chargeId": "cha_...", "metadata": { "orderId": "pedido-1001" } }
```

Em qualquer caso, guarde também um índice local `chargeId → pedido` ao criar a cobrança. É o que garante a correlação se o webhook chegar antes do esperado.

Os payloads completos de cada evento estão no contrato OpenAPI, seção `webhooks`: `/openapi.json`

## Catálogo de eventos

### Pagamentos

Confirmação, falha e cobranças vinculadas a pagamentos avulsos ou recorrentes.

| Evento | Quando |
|---|---|
| `payment.success` | Pagamento confirmado. O payload difere entre cobrança avulsa via Pix direto e cobrança vinculada a uma assinatura. |
| `payment.overdue` | Ciclo vencido sem pagamento. A assinatura passa a `DEFAULT` quando estava `ACTIVE`, ou a `PAST_DUE` quando estava `PENDING` ou `TRIALING`. |
| `charge.expired` | Cobrança expirou sem pagamento. Só ocorre a partir de PENDING, PROCESSING ou AWAITING_PAYMENT; o `status` no payload é o da COBRANÇA. |
| `payment.failed` | Tentativa de pagamento falhou. Schema próprio: não carrega a base de assinatura e usa `accountId`. |
| `charge.created` | Cobrança gerada. Traz a base da assinatura mais os dados da cobrança — atenção: `status` aqui é o status da COBRANÇA. |

### Assinaturas

Ciclo de vida da assinatura: criação, trial, ativação, renovação, cancelamento e mudanças de plano.

| Evento | Quando |
|---|---|
| `subscription.created` | Assinatura criada, aguardando primeiro pagamento. |
| `subscription.trial` | Assinatura entrou em período de teste. Não carrega objeto `trial`: o prazo está em `items[].price.trialDays`. |
| `subscription.activated` | Assinatura ativada após confirmação de pagamento. |
| `subscription.renewed` | Ciclo renovado e pago. Quando emitido pelo motor de cobrança, acrescenta os campos do próximo ciclo. |
| `subscription.upgraded` | Plano da assinatura alterado para cima. |
| `subscription.item_added` | Novo item adicionado à assinatura. |
| `subscription.item_removed` | Item removido da assinatura. |
| `subscription.downgrade_scheduled` | Downgrade agendado para o fim do ciclo. |
| `subscription.canceled` | Assinatura cancelada. O ciclo corrente continua no payload, com o status em que ficou. |
| `subscription.expired` | Assinatura expirada. O evento é assinável e o caminho de envio existe no backend; confirme com o time se algum fluxo já o dispara. |
| `subscription.cancel_scheduled` | Cancelamento agendado; assinatura segue ativa até o fim do ciclo. |

### Devoluções

Devolução de Pix e estorno de cartão.

| Evento | Quando |
|---|---|
| `refund.requested` | Devolução registrada e em processamento. |
| `refund.confirmed` | Valor devolvido ao pagador. |
| `refund.failed` | Devolução recusada ou falhou. O status enviado é `ERROR`. |

### Onboarding

Etapas da abertura de subconta, do envio da proposta à criação da conta.

| Evento | Quando |
|---|---|
| `onboarding.create` | Conta da subconta criada. |
| `onboarding.backgroundcheck` | Análise de background concluída. |
| `onboarding.documentscopy` | Etapa de documentoscopia atualizada. |
| `onboarding.proposal` | Status final da proposta. |

### MED

Mecanismo Especial de Devolução do Pix: bloqueios de saldo e contestações.

| Evento | Quando |
|---|---|
| `med.infraction.updated` | Infração do MED atualizada. |
| `med.balance.blocked` | Saldo bloqueado por ordem do MED. |
| `med.balance.unblocked` | Saldo desbloqueado. |
| `med.refund.opened` | Processo de devolução do MED aberto. |
| `med.refund.closed` | Processo de devolução do MED encerrado. |


### Onde assinar cada evento

A tela de webhooks do painel lista 16 eventos. Os nove abaixo também são emitidos, mas só podem ser assinados enviando o nome no array `events` de `POST /v1/users/webhooks`:

`subscription.upgraded`, `subscription.item_added`, `subscription.item_removed`, `subscription.downgrade_scheduled` e os cinco `med.*`.

## Testando

```http
POST /v1/users/webhooks/test
Authorization: Bearer {token}
Content-Type: application/json

{ "webhookId": "uwh_1783102664449_bs7mh0unf", "entity": "subscription.created" }
```

| Campo | Obrigatório | Descrição |
|---|---|---|
| `entity` | sim | Nome do evento |
| `webhookId` | condicional | Usa URL, `secret` e `authToken` do cadastro |
| `url` | condicional | URL de destino; obrigatório se não informar `webhookId` |

Com `webhookId`, o POST enviado inclui `X-Webhook-Signature`, igual ao envio real. A resposta da rota de teste é um envelope de diagnóstico — **o seu endpoint recebe apenas o conteúdo do campo `payload`**.

O corpo do teste é gerado com dados fictícios e nem sempre reproduz o payload de produção. Use-o para validar conectividade e assinatura; para o contrato dos campos, siga o OpenAPI e os guias de cada domínio. As diferenças conhecidas hoje: o teste inclui um objeto `trial` em `subscription.trial` e um `currentCycle: null` em `subscription.canceled` que não existem no envio real, e usa `accountNumber` em eventos que em produção saem com `accountId`.

## Notificações por e-mail

Não confundir com webhooks. No checkout transparente, o campo `notifications` controla quais e-mails saem naquela cobrança: `oneoff.pix.generated` (envia o QR ao comprador), `oneoff.payment.success` (confirmação ao comprador) e `new.sale` (avisa você da venda paga). Os eventos destinados ao comprador exigem `customer.email`.
