> ## Documentation Index
> Fetch the complete documentation index at: https://docs.purin.cash/llms.txt
> Use this file to discover all available pages before exploring further.

# Como funcionam os webhooks

> Quem avisa quem, o que a sua URL precisa ter e o que acontece quando ela cai.

Quando algo acontece com o seu dinheiro, a gente faz um `POST` na sua URL com o evento em
JSON. É assim que você sabe que o PIX caiu sem ficar consultando a API.

## Duas configurações diferentes

> [card] URLs da conta, no painel
>
> Até **cinco**, configuradas em [API e
> Desenvolvedores](https://purin.cash/dashboard/api). São o destino padrão de
> **todo** evento da conta.
>
> Se você só quer um endereço para tudo, configure uma e ignore o resto.

> [card] callbackUrl, por cobrança
>
> Você manda no corpo da requisição ao criar. Vale só para aquela cobrança, e
> tem prioridade sobre as URLs da conta.
>
> Use quando cobranças diferentes precisam avisar sistemas diferentes.

> [!NOTE]
>
> As duas não somam: quando a cobrança tem `callbackUrl`, o evento vai **só** para
> ela. As URLs da conta preenchem o que ficou sem destino, não duplicam o que já tem.

> [!NOTE]
>
> Com mais de uma URL da conta, cada uma recebe uma cópia do evento e tem a **própria**
> fila de reentrega. Um endereço fora do ar não atrasa nem cancela a entrega nos outros, e
> a tela de entregas mostra qual deles falhou.
>
> Todas recebem a mesma assinatura e o mesmo `X-Webhook-Id`. Se dois dos seus sistemas
> gravam no mesmo banco, deduplique por esse header nos dois.

## O que cada URL precisa ter

| Requisito | Detalhe |
| - | - |
| HTTPS | Obrigatório. `http://` é recusado na criação |
| Domínio público | Só nome de domínio. IP numérico, IPv6, `localhost` e rede privada são bloqueados |
| Resposta rápida | `2xx` em até **5 segundos**. Fora disso conta como falha |
| Tamanho | Até 500 caracteres |

URL inválida devolve `400` na criação da cobrança, com
`callbackUrl must be a valid, public HTTPS URL`. O erro vem na hora, não na entrega.

> [!TIP]
>
> Em desenvolvimento, use um túnel (`ngrok http 3000`, `cloudflared tunnel`) para expor o
> seu localhost com HTTPS público. Passar `http://localhost:3000` não funciona por design.

## Headers

| Header | Conteúdo |
| - | - |
| `Content-Type` | Sempre `application/json` |
| `X-Webhook-Signature` | HMAC-SHA256 em hex do corpo cru. Veja [Validando a assinatura](https://docs.purin.cash/webhooks/assinatura.md) |
| `X-Webhook-Id` | Chave de idempotência no formato `evento:id`, por exemplo `payment.paid:psa_abc123` |

> [!WARNING]
>
> `X-Webhook-Signature` vem em **todo** webhook, sem opt-in. Se você não valida, a sua URL
> aceita qualquer POST da internet dizendo que um pagamento foi aprovado.

## Reentrega

Se a entrega falhar (timeout, erro de rede ou status fora de `2xx`), o evento entra na
fila e é reenviado.

| Tentativa | Quando |
| - | - |
| 1 | Na hora do evento |
| 2 | 1 minuto depois |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 2 horas |
| 6 | 6 horas |
| 7 | 12 horas |

São até **7 tentativas**, cobrindo pouco mais de 20 horas. O corpo reenviado é idêntico ao
original e o `X-Webhook-Id` não muda.

> [!WARNING]
>
> É exatamente por causa da reentrega que o seu handler precisa ser idempotente. Deduplique
> pelo `X-Webhook-Id` antes de fazer qualquer coisa que não dá pra desfazer, como creditar
> saldo ou enviar licença.

## Eventos

Todos caem nas URLs da conta. A coluna abaixo diz quem consegue desviar disso.

| Evento | Dispara quando | Destino específico |
| - | - | - |
| `payment.paid` | Pagamento `psa_` confirmado | `callbackUrl` da cobrança |
| `charge.paid` | Cobrança `psc_` confirmada | `callbackUrl` da cobrança |
| `card_payment.paid` | Pagamento no cartão confirmado | `callbackUrl` da cobrança |
| `order.paid` | Pedido da loja do Discord pago | `order_webhook_url` da loja |
| `withdrawal.requested` | Saque solicitado | Não tem |
| `withdrawal.completed` | Saque pago | Não tem |
| `withdrawal.denied` | Saque negado antes de sair; saldo devolvido | Não tem |
| `withdrawal.failed` | Saque tentado e não concretizado; saldo devolvido | Não tem |
| `pix_payment.completed` | [Pagamento de código copia e cola](https://docs.purin.cash/guias/saques.md#pagar-um-codigo-copia-e-cola) concluído | Não tem |
| `pix_payment.failed` | Pagamento de código recusado; saldo devolvido | Não tem |
| `dispute.opened` | Contestação aberta contra uma venda sua | Não tem |
| `dispute.under_review` | Contestação em análise pelo banco | Não tem |
| `dispute.won` | Contestação ganha, valor liberado | Não tem |
| `dispute.lost` | Contestação perdida, valor devolvido ao cliente | Não tem |
| `subaccount.transfer` | Transferência de/para uma [subconta](https://docs.purin.cash/webhooks/subcontas.md), pela API ou pelo painel | Não tem |
| `subaccount.payout` | Saque turbo debitando uma subconta | Não tem |
| `subaccount.payout_reversal` | Banco negou o saque; atribuição voltou pra subconta | Não tem |

Em sandbox, a marca de teste entra depois do prefixo da família: os eventos de saque
saem como `withdrawal.test.requested` e `withdrawal.test.completed`.

Pagamento de código tem família própria (`pix_payment.*`) em vez de reusar
`withdrawal.*`: quem já concilia saques não passa a receber pagamento no mesmo balde.
O identificador acompanha a família: o corpo traz `paymentId`, que é o mesmo
`payment.id` devolvido pelo pagamento.

### Corpo dos eventos de contestação

Os quatro `dispute.*` mandam o mesmo corpo, mudando o `event` e o `status`:

```json theme={null}
{
  "event": "dispute.lost",
  "disputeId": "652f1c9a4b2e8f0012ab34cd",
  "code": "DSP-7K2M9Q",
  "status": "perdida",
  "statusLabel": "perdida",
  "previousStatus": "pendente",
  "orderCode": "A1B2C3D4E5",
  "endToEndId": "E00038166202603181230abcdef12345",
  "amount": 149.9,
  "currency": "BRL",
  "reason": "Nao reconheco a compra",
  "occurredAt": "2026-03-18T12:30:00.000Z"
}
```

`previousStatus` vem `null` na primeira vez que avisamos daquela contestação.
Use o `code` para casar com o que aparece no painel.

Nesses eventos o `X-Webhook-Id` é `evento:disputeId`, por exemplo
`dispute.opened:652f1c9a4b2e8f0012ab34cd`. Cada mudança de status de cada disputa tem a
sua chave. Para responder a disputa pela API, veja [Disputas](https://docs.purin.cash/guias/disputas.md).

## O jeito certo de tratar

**1. Leia o corpo cru**

Antes de qualquer parse de JSON. A assinatura é calculada sobre os bytes exatos que
chegaram.

**2. Valide a assinatura**

Com comparação timing-safe. Se não bater, responda `401` e pare por aí.

**3. Deduplique pelo X-Webhook-Id**

Já processou esse id? Responda `200` e não faça nada.

**4. Grave e responda 200**

Salve o evento e responda imediatamente. O processamento pesado vai para uma fila.

**5. Confira o valor antes de entregar**

`amountCents` precisa bater com o que você esperava. Status `paid` sozinho não basta.

> [!NOTE]
>
> Não conte com a ordem de chegada. Se a ordem importa para você, confirme o estado atual
> com um `GET` na API antes de decidir.

> [card] [Validando a assinatura](https://docs.purin.cash/webhooks/assinatura.md)
>
> Código pronto em Node, PHP, Python e Go.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.