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

# Webhooks

> Receba eventos da Liquerapay no seu backend em tempo real.

Webhooks são **mensagens HTTP POST** que a Liquerapay envia para uma URL
sua sempre que algo importante acontece (cobrança paga, saque concluído, etc.).
É a forma recomendada de manter seu sistema sincronizado — muito mais
eficiente do que fazer polling.

## Fluxo geral

<Steps>
  <Step title="Você cadastra uma URL HTTPS e escolhe os eventos">
    Use [`POST /webhooks`](/pages/webhooks/create) ou cadastre pelo painel,
    informando quais eventos (`events`) você quer receber. Nós te devolvemos
    um `secret` — a **chave de assinatura**.
  </Step>

  <Step title="Acontece um evento">
    Por exemplo: o cliente paga uma cobrança PIX.
  </Step>

  <Step title="Disparamos um POST">
    Enviamos uma requisição `POST` para a sua URL com o payload do evento e
    o header de assinatura.
  </Step>

  <Step title="Você responde 2xx">
    Seu backend valida a assinatura, processa o evento e responde com
    **HTTP 2xx** rapidamente. Se responder erro 5xx ou demorar muito
    (timeout), fazemos retry.
  </Step>
</Steps>

## Estrutura do payload

Todo webhook tem o mesmo formato base:

```json theme={null}
{
  "id": "evt_018fa4c76d8e7f209a1b",
  "event": "CHARGE_PAID",
  "createdAt": "2026-04-19T18:34:55.000Z",
  "data": {
    "chargeId": "chg_018f8e2d9a5f7b10c3d4",
    "amount": 5000,
    "status": "PAID",
    "txid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f",
    "end2endId": "E12345678202604191834550000000001",
    "paidAt": "2026-04-19T18:34:55.000Z",
    "customer": { "name": "João Silva", "document": "12345678900", "bank": "Nubank" }
  }
}
```

* **`id`**: identificador único **desta entrega de evento** (`evt_...`) — use como chave de idempotência
* **`event`**: nome do evento, ver tabela abaixo
* **`createdAt`**: quando o evento ocorreu
* **`data`**: dados específicos do evento

<Note>
  **Beta:** o shape de `data` pode ganhar novos campos sem aviso. Trate o
  payload como append-only e ignore campos/eventos desconhecidos.
</Note>

## Eventos disponíveis

Ao cadastrar um endpoint você escolhe de **1 a 8 eventos** em `events` — o
endpoint só recebe os eventos que ele está inscrito, não recebe tudo por padrão.

| Evento                 | Quando é disparado                                                                          |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| `CHARGE_PAID`          | Cobrança PIX (Checkout Transparente) foi paga com sucesso                                   |
| `REFUND_COMPLETED`     | Uma devolução foi confirmada pelo PSP                                                       |
| `REFUND_FAILED`        | Uma devolução falhou                                                                        |
| `WITHDRAWAL_COMPLETED` | Saque foi processado e o PIX enviado                                                        |
| `WITHDRAWAL_FAILED`    | Saque falhou ou foi devolvido                                                               |
| `CHECKOUT_PAID`        | Cobrança de um Checkout Hospedado / link de pagamento foi paga                              |
| `CHECKOUT_REFUNDED`    | Cobrança de um Checkout Hospedado / link de pagamento foi devolvida                         |
| `CHARGE_EXPIRED`       | Reservado para cobrança expirada — **ainda não é disparado hoje**, mas já pode ser inscrito |

<Note>
  `CHECKOUT_PAID` e `CHECKOUT_REFUNDED` pertencem ao Checkout Hospedado
  (documentação completa em breve). `CHARGE_EXPIRED` existe no schema mas
  nenhum job dispara esse evento atualmente — não dependa dele ainda.
</Note>

## Header enviado

Toda requisição `POST` para a sua URL inclui:

| Header              | Conteúdo                                            |
| ------------------- | --------------------------------------------------- |
| `Content-Type`      | `application/json`                                  |
| `User-Agent`        | `Liquera-Webhook/1.0`                               |
| `Liquera-Signature` | Assinatura no formato `t=<timestamp>,v1=<hmac_hex>` |

<Warning>
  O header de assinatura é **`Liquera-Signature`**. Não confunda com
  `X-Webhook-Signature`, `X-Liquera-Signature` ou variações — é exatamente
  `Liquera-Signature`.
</Warning>

## Validando a assinatura

A `chave de assinatura` (`secret`) é devolvida **uma única vez**, na resposta
do `POST /webhooks`. Guarde-a em variável de ambiente ou cofre — é ela que
prova que uma requisição realmente veio da Liquerapay.

O header `Liquera-Signature` segue o formato `t=<timestamp>,v1=<assinatura>`:

* **`t`**: timestamp Unix (segundos) de quando o evento foi assinado
* **`v1`**: HMAC-SHA256, em hex, calculado sobre a string `"${t}.${corpo_bruto}"`
  (o timestamp, um ponto, e o **corpo bruto da requisição** — antes de
  `JSON.parse`), usando o `secret` do endpoint como chave

Para validar: extraia `t` e `v1` do header, recalcule o HMAC da mesma forma
e compare com `v1` usando uma comparação em **tempo constante**.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto'

  function verify(req, secret) {
    const header = req.headers['liquera-signature']
    if (!header) return false

    const parts = Object.fromEntries(
      header.split(',').map((p) => p.split('=')),
    )
    const { t: timestamp, v1: signature } = parts
    if (!timestamp || !signature) return false

    const signedPayload = `${timestamp}.${req.rawBody}` // body bruto, string ou Buffer
    const expected = crypto
      .createHmac('sha256', secret)
      .update(signedPayload)
      .digest('hex')

    const valid =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))

    // opcional, mas recomendado: rejeitar timestamps muito antigos
    const toleranceSeconds = 300
    const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= toleranceSeconds

    return valid && fresh
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  def verify(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
      parts = dict(p.split('=', 1) for p in header.split(','))
      timestamp, signature = parts.get('t'), parts.get('v1')
      if not timestamp or not signature:
          return False

      signed_payload = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

      valid = hmac.compare_digest(signature, expected)
      fresh = abs(time.time() - int(timestamp)) <= tolerance_seconds
      return valid and fresh
  ```
</CodeGroup>

<Warning>
  **Sempre** valide a assinatura antes de processar o evento — sem isso,
  qualquer pessoa pode fazer POST na sua URL e simular eventos. A API não
  impõe uma janela de tolerância no `t`; recomendamos rejeitar timestamps
  com mais de alguns minutos de diferença para evitar replay de requisições
  antigas.
</Warning>

## Política de retry

Quando uma tentativa falha, o comportamento depende do tipo de falha:

* **Erro `4xx`** (ex.: sua URL retorna `400`/`404`): tratamos como rejeição
  definitiva do seu lado — **não tentamos de novo**.
* **Erro `5xx`, timeout ou falha de rede**: reagendamos automaticamente com
  **backoff exponencial** (base de 2 segundos), até **3 tentativas no total**.

| Tentativa | Quando                                                  |
| --------- | ------------------------------------------------------- |
| 1         | Imediata                                                |
| 2         | \~2 segundos depois, se a 1ª falhar com erro retentável |
| 3         | \~4 segundos depois, se a 2ª falhar com erro retentável |

Timeout de cada request: **10 segundos**. Depois da 3ª tentativa, a entrega
fica `FAILED` definitivamente.

<Warning title="Desativação automática">
  Se um endpoint acumular **10 ou mais entregas com falha nas últimas 24h**,
  ele é **desativado automaticamente** (`active: false`) e para de receber
  eventos. Hoje não há uma rota de auto-reativação — se isso acontecer,
  contate o suporte ou remova o endpoint (`DELETE`) e cadastre um novo
  (`POST`), o que também gera um novo `secret`.
</Warning>

Use [`GET /webhooks/{id}/deliveries`](/pages/webhooks/deliveries) para
inspecionar o histórico de entregas (status HTTP, sucesso, tentativa e erro)
de um endpoint e debugar falhas.

## Idempotência

Como podemos retentar entregas, **o mesmo evento pode chegar mais de uma vez**.
Implemente idempotência usando o `id` do topo do payload (`evt_...`) como
chave única.

```javascript theme={null}
const alreadyProcessed = await db.webhookEvent.findUnique({
  where: { id: payload.id },
})
if (alreadyProcessed) return reply.status(200).send()
```

## Boas práticas

<Card title="Recomendações" horizontal>
  * Responda **rápido** (idealmente \< 2s) — mova trabalho pesado para fila
  * **Sempre** valide a assinatura `Liquera-Signature`
  * Implemente **idempotência** pelo `id` do payload
  * Tenha **logs** de todos os payloads recebidos para debugging
  * Use **HTTPS** com certificado válido
  * **Não dependa de ordem** de entrega — eventos podem chegar fora de ordem
</Card>
