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

# Webhooks

> Receba eventos de pagamento assinados, valide a assinatura e reaja a eles.

Webhooks são como a Gravity Play avisa o seu sistema quando algo acontece — um pagamento
confirmado, uma assinatura renovada, um estorno. Eles são a **fonte da verdade**: chegam mesmo
que o cliente feche o navegador.

## Duas formas de receber

<CardGroup cols={2}>
  <Card title="Endpoints do painel">
    Cadastre URLs fixas em **Integrações → Webhooks** e escolha os eventos (e,
    se quiser, os produtos). Recebem **todos** os eventos que casarem.
  </Card>

  <Card title="postbackUrl da cobrança">
    Ao criar uma cobrança via API, informe um `postbackUrl`. Ele recebe os
    eventos **daquela transação específica** — somado aos endpoints do painel.
  </Card>
</CardGroup>

## Eventos

| Evento                        | Quando dispara                                                   |
| ----------------------------- | ---------------------------------------------------------------- |
| `payment.pending`             | Uma cobrança foi criada e aguarda pagamento (PIX/boleto gerado). |
| `payment.paid`                | Um pagamento foi confirmado.                                     |
| `payment.completed`           | Compra concluída — a janela de estorno expirou e segue paga.     |
| `payment.failed`              | Um pagamento foi recusado.                                       |
| `payment.refunded`            | Um pagamento foi estornado.                                      |
| `payment.chargeback`          | Um pagamento sofreu chargeback.                                  |
| `subscription.created`        | Uma assinatura ficou ativa.                                      |
| `subscription.renewed`        | Uma assinatura foi renovada (novo ciclo pago).                   |
| `subscription.canceled`       | Uma assinatura foi cancelada.                                    |
| `subscription.payment_failed` | A cobrança de uma assinatura falhou (dunning).                   |

## Formato do payload

Toda entrega é um `POST` com corpo JSON neste formato:

```json theme={null}
{
  "id": "whd_9f2c...",
  "event": "payment.paid",
  "createdAt": "2026-06-10T01:19:06.544Z",
  "data": {
    "id": "pay_...",
    "provider": "STRIPE",
    "status": "PAID",
    "amountInCents": 12900,
    "currency": "usd",
    "productId": "prod_...",
    "offerId": "off_...",
    "subscriptionId": "sub_...",
    "chargeId": "chg_2e59538f...",
    "customerEmail": "client@acme.com",
    "customer": {
      "name": "Maria Silva",
      "email": "client@acme.com",
      "document": "12345678909",
      "phone": "11987654321",
      "address": {
        "zipCode": "01310100",
        "street": "Avenida Paulista",
        "number": "1000",
        "complement": "Conj 101",
        "neighborhood": "Bela Vista",
        "city": "São Paulo",
        "state": "SP"
      }
    },
    "metadata": { "invoiceId": "1234" },
    "createdAt": "2026-06-10T01:19:06.544Z",
    "offer": {
      "id": "off_...",
      "name": "Plano Pro",
      "description": "Acesso completo",
      "billingType": "RECURRING",
      "billingCycle": "MONTHLY",
      "priceInCents": 12900,
      "currency": "usd",
      "setupFeeInCents": null,
      "trialDays": null,
      "maxCharges": null,
      "statementDescriptor": "ACME PRO",
      "product": { "id": "prod_...", "name": "Acme SaaS" }
    }
  }
}
```

Campos do `data` nos eventos `payment.*`:

| Campo            | Descrição                                                               |
| ---------------- | ----------------------------------------------------------------------- |
| `id`             | Id do pagamento na Gravity Play.                                        |
| `provider`       | Gateway que processou (`STRIPE`, `ASAAS`, `EFI`, `PAGARME`).            |
| `status`         | `PENDING`, `PAID`, `FAILED`, `REFUNDED`, `EXPIRED` (PIX vencido).       |
| `amountInCents`  | Valor em centavos.                                                      |
| `currency`       | Moeda (ex.: `brl`, `usd`).                                              |
| `productId`      | Produto associado (pode ser `null` em cobranças avulsas).               |
| `offerId`        | Oferta associada (`null` em cobranças avulsas via API).                 |
| `subscriptionId` | Assinatura, quando o pagamento é de um ciclo (`null` caso contrário).   |
| `chargeId`       | Id da cobrança avulsa (`/v1/charges`) que originou o evento, se houver. |
| `customerEmail`  | E-mail do comprador. **Legado** — prefira `customer.email`.             |
| `customer`       | Objeto completo do comprador (veja abaixo).                             |
| `metadata`       | A metadata que você enviou na criação da cobrança/oferta.               |
| `createdAt`      | Quando o pagamento foi criado.                                          |
| `offer`          | Objeto da oferta (veja abaixo). Presente sempre que houver `offerId`.   |

### O objeto `customer`

Todos os eventos `payment.*` e `subscription.*` trazem o objeto **`customer`** com os dados que
o comprador preencheu no checkout. Campos não coletados naquele fluxo vêm como `null`.

| Campo      | Descrição                                                                      |
| ---------- | ------------------------------------------------------------------------------ |
| `name`     | Nome completo do comprador.                                                    |
| `email`    | E-mail do comprador.                                                           |
| `document` | CPF/CNPJ, só dígitos (checkouts BR — `null` em pagamentos via Stripe).         |
| `phone`    | Telefone com DDD, só dígitos (quando o método coleta — `null` caso contrário). |
| `address`  | Endereço de cobrança (quando o método coleta) ou `null`.                       |

O `address`, quando presente, tem o formato:

| Campo          | Descrição                              |
| -------------- | -------------------------------------- |
| `zipCode`      | CEP, só dígitos.                       |
| `street`       | Rua/logradouro.                        |
| `number`       | Número.                                |
| `complement`   | Complemento (`null` se não informado). |
| `neighborhood` | Bairro.                                |
| `city`         | Cidade.                                |
| `state`        | UF (2 letras).                         |

<Note>
  **Quando cada campo existe.** O endereço é coletado no **cartão**
  (Asaas/Pagar.me) e no **boleto** — em pagamentos **PIX** o checkout pede só os
  dados pessoais, então `customer.address` vem `null`. O telefone é coletado no
  cartão (gateways BR) e no PIX Pagar.me. Pagamentos anteriores a esta versão
  não têm os campos novos preenchidos (`null`).
</Note>

Para eventos de uma cobrança via API, o `data.metadata` traz a metadata que você enviou na
criação — use o `invoiceId` (ou o que for) para dar baixa.

### O objeto `offer`

Todo evento cujo `data` tenha um `offerId` — pagamentos de checkout de oferta e **todos** os
eventos `subscription.*` — vem com o objeto **`offer`** completo embutido, para você reagir aos
dados comerciais (nome, preço, ciclo) sem uma chamada extra à API.

| Campo                 | Descrição                                                       |
| --------------------- | --------------------------------------------------------------- |
| `id`                  | Id da oferta.                                                   |
| `name`                | Nome da oferta.                                                 |
| `description`         | Descrição da oferta (`null` se não houver).                     |
| `billingType`         | `ONE_TIME` ou `RECURRING`.                                      |
| `billingCycle`        | `WEEKLY`, `MONTHLY`, `YEARLY`… (`null` em `ONE_TIME`).          |
| `priceInCents`        | Preço da oferta em centavos.                                    |
| `currency`            | Moeda da oferta.                                                |
| `setupFeeInCents`     | Taxa de adesão (1º ciclo), se houver (`null`).                  |
| `trialDays`           | Dias de teste antes da 1ª cobrança, se houver (`null`).         |
| `maxCharges`          | Nº máximo de cobranças da recorrência, se houver (`null`).      |
| `statementDescriptor` | Texto que aparece na fatura do cartão (`null` se não definido). |
| `product`             | `{ id, name }` do produto da oferta.                            |

<Note>
  **Cobranças avulsas não têm oferta.** Eventos de cobranças criadas via `POST
      /v1/charges` (sem uma oferta) **não** trazem o objeto `offer` — nesses casos
  `offerId` é `null`. Use o `data.metadata` e o `chargeId` para reconciliar.
</Note>

Os eventos `subscription.*` usam um `data` menor (`id`, `status`, `offerId`, `customerEmail`,
`customer`, `currentPeriodEnd`) — também com os objetos `customer` e `offer` embutidos:

```json theme={null}
{
  "id": "whd_...",
  "event": "subscription.renewed",
  "createdAt": "2026-06-10T01:19:06.544Z",
  "data": {
    "id": "sub_...",
    "status": "active",
    "offerId": "off_...",
    "customerEmail": "client@acme.com",
    "customer": {
      "name": "Maria Silva",
      "email": "client@acme.com",
      "document": "12345678909",
      "phone": "11987654321",
      "address": null
    },
    "currentPeriodEnd": "2026-07-10T01:19:06.544Z",
    "offer": {
      "id": "off_...",
      "name": "Plano Pro",
      "billingType": "RECURRING",
      "billingCycle": "MONTHLY",
      "priceInCents": 12900,
      "currency": "usd",
      "product": { "id": "prod_...", "name": "Acme SaaS" }
    }
  }
}
```

E os headers:

| Header                   | Descrição                                              |
| ------------------------ | ------------------------------------------------------ |
| `X-GravityPay-Event`     | O tipo do evento (ex.: `payment.paid`).                |
| `X-GravityPay-Delivery`  | Id único da entrega (= `id` do payload). Idempotência. |
| `X-GravityPay-Signature` | Assinatura HMAC (veja abaixo).                         |

## Validando a assinatura

Cada entrega é assinada com HMAC-SHA256. O header tem o formato `t=<unix>,v1=<hmac>`, e o HMAC
é calculado sobre `` `${t}.${body}` `` usando o **secret**.

<Note>
  **Qual secret usar?** - **Endpoints do painel:** o *signing secret* exibido ao
  criar o endpoint. - **postbackUrl:** o **`postbackSecret`** retornado na
  resposta do `POST /v1/charges` daquela cobrança. Cada cobrança tem o seu.
</Note>

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

  function verify(secret, header, rawBody) {
    const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.${rawBody}`)
      .digest("hex");
    return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  }

  // Express — use o corpo CRU (raw), não o JSON já parseado.
  app.post("/webhooks/gravitypay", express.raw({ type: "*/*" }), (req, res) => {
    const ok = verify(
      process.env.POSTBACK_SECRET,
      req.headers["x-gravitypay-signature"],
      req.body.toString("utf8"),
    );
    if (!ok) return res.status(400).end();

    const event = JSON.parse(req.body.toString("utf8"));
    if (event.event === "payment.paid") {
      // dê baixa na fatura event.data.metadata.invoiceId
    }
    res.status(200).end();
  });
  ```

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

  def verify(secret: str, header: str, raw_body: str) -> bool:
      parts = dict(p.split("=") for p in header.split(","))
      expected = hmac.new(
          secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(parts["v1"], expected)
  ```
</CodeGroup>

<Warning>
  **Use o corpo cru.** Valide a assinatura sobre o **corpo exatamente como
  recebido** (raw). Se você re-serializar o JSON, a assinatura não vai bater.
</Warning>

## postbackUrl

Quando você cria uma cobrança com `postbackUrl`, todos os eventos daquela transação
(`payment.paid`, `payment.failed`, `payment.refunded`) são entregues **também** nessa URL —
além dos endpoints do painel. É ideal para reconciliar uma fatura sem precisar manter um endpoint
fixo.

O `postbackSecret` que valida essas entregas vem **uma única vez**, na resposta da criação da
cobrança. Guarde-o junto com o id da cobrança/fatura.

## Idempotência

A mesma entrega pode chegar mais de uma vez (ex.: ao reprocessar). Use o
`X-GravityPay-Delivery` (= `id` do payload) como chave de idempotência e ignore duplicatas.

## Resposta e re-tentativas

Responda **2xx** rapidamente (idealmente em até alguns segundos) para confirmar o recebimento.
Qualquer outro status é tratado como falha. Entregas que falham ficam registradas no painel
(**Integrações → Webhooks → Entregas**), de onde você pode **reprocessar** manualmente.
