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

# Webhooks

> Receba notificações assinadas no seu servidor quando seus pagamentos mudam de estado: creditado, com falha ou registrado.

Os **webhooks** avisam você em tempo real, no seu próprio servidor, sempre que um
pagamento muda de estado — sem precisar consultar a API em loop. A 1to1 envia um
`POST` assinado para a URL que você registrar, com os detalhes do pagamento.

<Note>
  Estes webhooks são **de saída** (1to1 → seu servidor). Você não os chama: você os
  recebe. Cada entrega é **assinada** (padrão [Standard Webhooks](https://www.standardwebhooks.com/))
  para que você verifique que veio de nós e não foi alterada.
</Note>

## Configurar um endpoint

No dashboard, em **Configurações → API e Conexões**, adicione a URL do seu sistema
e escolha os eventos que quer receber. Ao criar, você recebe um **signing secret**
(`whsec_…`) mostrado **uma única vez** — guarde-o como um segredo: é a chave com
que você verifica cada webhook. Você pode registrar até **10 endpoints** por negócio.

<Warning>
  A URL deve ser **`https://`** e pública. Guarde o `whsec_…` em um lugar seguro do
  servidor (variável de ambiente, secret manager) — nunca em código cliente nem no
  repositório. Se perdê-lo, **rotacione** o secret pelo dashboard.
</Warning>

## Catálogo de eventos

| Evento               | Quando dispara                                                                                         |
| -------------------- | ------------------------------------------------------------------------------------------------------ |
| `payment.credited`   | Um pagamento foi **creditado** — cartão (online), SPEI, ou pagamento manual creditado pela sua equipe. |
| `payment.failed`     | Um pagamento **manual** foi marcado como falho pela sua equipe.                                        |
| `payment.registered` | Um pagamento **manual** foi registrado, pendente de creditar.                                          |
| `ping`               | Evento de teste do botão **Testar** do dashboard.                                                      |

<Note>
  `payment.failed` cobre **apenas** os pagamentos manuais marcados como falhos pela
  sua equipe. As falhas de pagamentos online (cartão recusado, sessão expirada)
  **não** emitem webhook.
</Note>

## O payload

Cada `POST` leva um corpo JSON com esta forma. O exemplo é um pagamento manual
creditado:

```jsonc theme={null}
{
  "id": "8f3b2c1a-...",              // id único desta entrega (= header webhook-id)
  "type": "payment.credited",
  "event_at": "2026-07-22T14:47:38.609Z",  // momento do evento; carimbado ao emitir, logo após credited_at
  "data": {
    "payment": {
      "uuid": "f893...",            // id estável do pagamento (use para correlacionar)
      "folio": "PAY-00000002",
      "status": "credited",         // credited | failed | pending
      "amount_cents": 15000,        // dinheiro SEMPRE em centavos + currency
      "credited_amount_cents": 15000,
      "currency": "MXN",
      "provider": "manual",         // manual | stripe | mercadopago | paypal
      "method": null,               // informativo (card | spei | ...); nunca "manual"
      "bank": null,                 // banco (SPEI-push); null em manual e cartão
      "bank_account": "BANORTE 0876", // só manual: a conta em que o dinheiro entrou
      "bank_datetime": "2026-07-22T14:47:00Z", // só manual, opcional
      "files": [                    // comprovantes (só manual); [] se não houver
        { "uuid": "92c9...", "name": "comprovante.pdf", "mime_type": "application/pdf" }
      ],
      "transaction_id": null,       // id de transação do provedor (online)
      "receipt_url": null,          // recibo hospedado do provedor (online), se houver
      "fail_reason": null,          // só com valor em payment.failed
      "credited_at": "2026-07-22T14:47:38.204Z"
    },
    "conversation_info": {
      "uuid": "c9ba...",            // null se a conversa foi excluída
      "phone": "529671941293",
      "inbox_status": "pending",
      "is_tester": false,           // sempre false (pagamentos de teste não emitem)
      "mailbox": { "name": "Vendas" }
    },
    "contact_info": {
      "first_name": "Maikol",
      "middle_name": null,
      "last_name": null,
      "second_last_name": null,
      "full_name": "Maikol",
      "username": null,             // handle do WhatsApp (sem "@"), se conhecido
      "phone": "529671941293",
      "whatsapp_user_id": "MX.1343..." // identidade do WhatsApp (ou telefone como fallback)
    }
  }
}
```

<Note>
  `event_at` é o momento em que o evento do pagamento ocorreu (para
  `payment.failed`, a hora da falha). É **diferente** do header `webhook-timestamp`,
  que é o instante de envio usado para a assinatura anti-replay.
</Note>

Alguns campos são **exclusivos de pagamentos manuais** (`provider: "manual"`):
`bank_account` (a etiqueta da conta em que o dinheiro entrou, ex.
`"BANORTE 0876"`), `bank_datetime` (data/hora no banco, opcional) e `files`
(comprovantes enviados). Em pagamentos online ficam `null` / `[]`. Para cartão e
SPEI online use `transaction_id` e `receipt_url`.

<Warning>
  `conversation_info.uuid` pode ser **`null`** se a conversa associada foi excluída
  — o pagamento notifica mesmo assim (o webhook é do ciclo de vida do
  **pagamento**, não da conversa). Sempre correlacione por `data.payment.uuid`.
</Warning>

## Headers de cada entrega

<ResponseField name="webhook-id" type="string">
  Id único da entrega. **Estável entre reenvios** de um mesmo evento — use-o para
  deduplicar (ver [Entrega](#entrega-e-reenvios)).
</ResponseField>

<ResponseField name="webhook-timestamp" type="integer">
  Instante de envio (segundos Unix). Recomputado a cada reenvio e faz parte da
  assinatura. Rejeite os que estiverem fora de uma janela razoável (±5 min) para
  se proteger de replays.
</ResponseField>

<ResponseField name="webhook-signature" type="string">
  Uma ou mais assinaturas `v1,<base64>` separadas por espaço (haverá **duas**
  durante uma rotação de secret). A entrega é válida se **alguma** coincidir.
</ResponseField>

<ResponseField name="x-1to1-event" type="string">
  O tipo de evento (`payment.credited`, `payment.failed`, …). Coincide com `type`
  do corpo.
</ResponseField>

<ResponseField name="user-agent" type="string">
  Sempre `1to1-Webhooks/1.0`.
</ResponseField>

## Verificar a assinatura

A assinatura é um **HMAC-SHA256** do conteúdo `{webhook-id}.{webhook-timestamp}.{body}`,
onde `body` é o corpo cru **exato** que você recebeu (não o re-serialize). A chave
é seu `whsec_…` com o prefixo removido e o resto decodificado de base64.

<Warning>
  Verifique sobre o **corpo cru** (raw body), antes de parsear o JSON.
  Re-serializar o objeto muda bytes (espaços, ordem das chaves) e quebra a
  assinatura.
</Warning>

A forma mais simples é a biblioteca oficial do Standard Webhooks, que cuida da
rotação e da comparação em tempo constante por você:

<CodeGroup>
  ```js JavaScript theme={null}
  import { Webhook } from "standardwebhooks";

  // whsec_… guardado em uma variável de ambiente
  const wh = new Webhook(process.env.WEBHOOK_SECRET);

  // rawBody = o corpo cru do request (string), NÃO o objeto já parseado
  const payload = wh.verify(rawBody, {
    "webhook-id": req.headers["webhook-id"],
    "webhook-timestamp": req.headers["webhook-timestamp"],
    "webhook-signature": req.headers["webhook-signature"],
  });
  // Se a assinatura não validar, verify() lança — responda 400 e não processe.
  ```

  ```python Python theme={null}
  import os
  from standardwebhooks import Webhook

  wh = Webhook(os.environ["WEBHOOK_SECRET"])

  # raw_body = o corpo cru do request (bytes/str), NÃO o dict já parseado
  payload = wh.verify(raw_body, {
      "webhook-id": headers["webhook-id"],
      "webhook-timestamp": headers["webhook-timestamp"],
      "webhook-signature": headers["webhook-signature"],
  })
  # Se a assinatura não validar, verify() lança — responda 400 e não processe.
  ```
</CodeGroup>

Se preferir verificar na mão (sem dependências), replique o HMAC e compare em
tempo constante contra cada assinatura do header:

<CodeGroup>
  ```js JavaScript theme={null}
  import crypto from "crypto";

  function verifyWebhook(rawBody, headers, secret) {
    const id = headers["webhook-id"];
    const ts = headers["webhook-timestamp"];

    // Rejeita replays fora de ±5 min (o webhook-timestamp faz parte da assinatura).
    if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) return false;

    const signedContent = `${id}.${ts}.${rawBody}`;

    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = crypto.createHmac("sha256", key).update(signedContent).digest("base64");

    // O header pode trazer várias assinaturas (rotação), separadas por espaço.
    const received = headers["webhook-signature"]
      .split(" ")
      .map((s) => s.replace(/^v1,/, ""));

    return received.some((sig) => {
      const a = Buffer.from(sig);
      const b = Buffer.from(expected);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    });
  }
  ```

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

  def verify_webhook(raw_body: str, headers: dict, secret: str) -> bool:
      # Rejeita replays fora de ±5 min (o webhook-timestamp faz parte da assinatura).
      if abs(int(time.time()) - int(headers["webhook-timestamp"])) > 300:
          return False

      signed_content = f'{headers["webhook-id"]}.{headers["webhook-timestamp"]}.{raw_body}'

      key = base64.b64decode(secret.removeprefix("whsec_"))
      expected = base64.b64encode(
          hmac.new(key, signed_content.encode(), hashlib.sha256).digest()
      ).decode()

      # O header pode trazer várias assinaturas (rotação), separadas por espaço.
      received = [s.removeprefix("v1,") for s in headers["webhook-signature"].split(" ")]
      return any(hmac.compare_digest(sig, expected) for sig in received)
  ```
</CodeGroup>

## Entrega e reenvios

<Steps>
  <Step title="Responda 2xx rápido">
    Responda com qualquer `2xx` assim que receber o webhook. Se demorar mais de
    **10 segundos** ou responder outro código, tratamos como falha e reenviamos.
  </Step>

  <Step title="Deduplique por webhook-id">
    A entrega é **at-least-once**: um mesmo evento pode chegar mais de uma vez (um
    reenvio após um timeout, por exemplo). O `webhook-id` é estável entre
    reenvios — guarde-o e descarte os repetidos.
  </Step>

  <Step title="Não assuma ordem">
    Os eventos **não** chegam garantidamente em ordem. Use `data.payment.uuid` +
    `status` como a verdade, não a ordem de chegada: um `payment.registered` que
    chegue atrasado não deve sobrescrever um `payment.credited` que você já
    processou.
  </Step>

  <Step title="Reenvios ~3 dias">
    Um endpoint fora do ar recebe reenvios com espaçamento crescente por \~3 dias.
    Se continuar falhando, a assinatura é **desabilitada** automaticamente (após 5
    falhas consecutivas esgotadas) — você a reativa pelo dashboard.
  </Step>
</Steps>

## Rotacionar o secret

Pelo dashboard você pode **rotacionar** o signing secret quando quiser. Durante um
**período de graça de 24 horas**, cada webhook é assinado com o secret **novo** e
o **anterior** ao mesmo tempo (duas assinaturas no header). Assim você atualiza
seu sistema sem perder nem rejeitar eventos: valide contra qualquer uma das duas;
quando termina o período de graça de 24 h, o secret anterior deixa de assinar
automaticamente.

## Comprovantes

Em um pagamento manual, `files[]` lista os comprovantes enviados com referências
estáveis — `uuid`, `name` e `mime_type` — mas **sem URL de download**: o payload é
um snapshot reenviado por dias, e uma URL assinada expiraria no caminho. O
download do arquivo é feito com sua API key por um endpoint autenticado
(documentado aqui quando disponível); enquanto isso, o `uuid` serve para
correlacionar o comprovante no dashboard.

## Próximos passos

<CardGroup cols={2}>
  <Card title="Autenticação" icon="key" href="/pt/authentication">
    Como sua integração se autentica com a API key.
  </Card>

  <Card title="Erros" icon="triangle-exclamation" href="/pt/errors">
    O contrato de erros da API pública.
  </Card>
</CardGroup>
