> ## Documentation Index
> Fetch the complete documentation index at: https://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 | paypal | ...); nunca "manual"
      "bank": null,                 // banco (SPEI-push); null em manual e cartão
      "bank_account": "BANORTE0876", // 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,       // online: id do provedor; manual: id de banco capturado ao creditar (null se não foi capturado)
      "receipt_url": null,          // null em manual; página do recibo em online creditado
      "fail_reason": null,          // só com valor em payment.failed: o motivo EM TEXTO LIVRE que sua equipe escreveu
      "credited_at": "2026-07-22T14:47:38.204Z",
      "description": "Pagamento parcial do pedido 4471",  // nota do pagamento escrita pela sua equipe ou pelo seu AI employee; null se não houver
      "custom_fields": {                                  // os campos personalizados que você definiu; {} se não houver nenhum
        "Tipo de pagamento": "Parcela",
        "Pedido": "4471"
      }
    },
    "conversation_info": {
      "uuid": "c9ba...",            // null se a conversa foi excluída
      "phone": "529671941293",
      "inbox_status": "pending",
      "is_tester": false,           // false nestes webhooks: não emitem pagamentos de teste
      "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 o telefone se ainda não houver BSUID)
    }
  }
}
```

<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.
`"BANORTE0876"`), `bank_datetime` (data/hora no banco, opcional) e `files`
(comprovantes enviados). Em pagamentos online ficam `null` / `[]`. Em
pagamentos manuais, `transaction_id` é o ID de banco capturado pelo operador
ao creditar (`null` se não foi capturado); em pagamentos online é o id do
provedor. Em **todo pagamento online creditado** `receipt_url` traz sempre uma
URL do nosso domínio (`https://…/pay/receipt/<uuid>`), nunca o recibo
hospedado do provedor; em pagamentos manuais é `null`. Essa página não exige
autenticação e não tem vencimento por tempo (ao contrário dos links do
provedor); deixa de servir se o pagamento for excluído ou deixar de estar
creditado. O PDF do recibo é baixado adicionando o sufixo `/pdf` a essa mesma
URL.

<Note>
  O payload **cresce com o tempo**: podemos adicionar campos novos ao webhook sem aviso
  prévio, e isso não conta como mudança incompatível. Os que já estão documentados aqui não
  são removidos nem renomeados sem anunciar antes, e uma mudança incompatível chega como um
  **tipo de evento novo**, nunca alterando um existente.

  Por isso seu parser deve **ignorar o que não reconhecer** —tanto chaves quanto tipos de
  evento— em vez de falhar: não configure `additionalProperties: false` na validação de
  schema, nem use structs que rejeitem campos desconhecidos. Uma entrega que seu endpoint
  rejeita é reenviada por \~3 dias e pode acabar desabilitando a assinatura.

  Isso se aplica ao **shape** do payload. Os limites de tamanho e o conteúdo dos campos de
  texto livre podem mudar: se você dimensionou seu armazenamento pelos topos acima, revise
  este guia antes de assumir que continuam os mesmos.
</Note>

### `description` e `custom_fields`: o contexto que sua equipe escreve

Dois campos que descrevem **do que se trata** o pagamento, vindos do que seu próprio
negócio captura:

* **`description`** é a nota do pagamento. Quem o registra a escreve no painel, ou seu AI
  employee a redige se você configurou um guia para isso. É prosa livre: `null` quando
  ninguém escreveu nada.
* **`custom_fields`** são os campos personalizados que você definiu na sua conta —«Tipo de
  pagamento», «Pedido», os que precisar— com o valor capturado naquele pagamento. Enquanto o campo estiver em
  liberação por etapas a chave **pode não vir**; quando vem é sempre um objeto: `{}` se nenhum
  foi capturado.

As chaves de `custom_fields` são os nomes que você deu, literalmente, então renomear um
campo na sua conta muda a chave nos pagamentos seguintes. Se for mapeá-los para o seu
sistema, mapeie pelo nome que usa hoje e revise esse mapeamento ao renomear.

Quando as duas chaves vêm, hoje quem as preenche é só o fluxo de pagamentos manuais: em
pagamentos com cartão ou link chegam como `null` e `{}`.

<Warning>
  Trate-os como **texto de um humano, não confiável para lógica**: escape-os antes
  de renderizá-los em HTML e não derive decisões automáticas da `description` nem
  dos valores de `custom_fields`. Podem trazer quebras de linha, acentos e emoji. A
  `description` é limitada a 2000 caracteres, cada valor de `custom_fields` a 500,
  o nome de cada campo a 100 e até 50 campos por pagamento, mas dimensione seu
  armazenamento por esses limites, não pelo que você vir nas primeiras entregas.
</Warning>

<Note>
  **Disponibilidade:** esses dois campos estão sendo habilitados por etapas **no nível da
  plataforma**, não conta por conta. Se você ainda não os vê nas suas entregas é porque ainda
  não foram publicados — fale com a gente e avisamos quando forem.
</Note>

### Se você recebe eventos por duas vias, deduplique por `payment.uuid`

Além destes webhooks, um AI employee do tipo JSON pode despachar o pagamento para uma URL
sua como parte do que extrai da conversa. Se usar as duas vias, **o mesmo pagamento vai
chegar duas vezes**, com o mesmo objeto `payment` em ambas.

Deduplique por `data.payment.uuid`, que é estável entre as duas. Não use o folio:
pagamentos com cartão e link nem sempre trazem um.

**Se você também usa um AI employee do tipo JSON** que anexa o pagamento, o que recebe dentro do campo dele é **este mesmo objeto `data` completo** —`payment`, `contact_info` e `conversation_info`—, então o código que já escreveu para processá-lo serve igual. A única coisa que não leva é o envelope (`id`, `type`, `event_at`), porque esse descreve esta entrega em particular.

<Warning>
  **Ali `conversation_info.is_tester` pode chegar como `true`.** Estes webhooks nunca lhe enviam pagamentos de teste, mas o AI employee do tipo JSON **despacha sim a partir do Tester**, para a URL que você configurou — é o que permite testar sua integração de ponta a ponta.

  Quando isso acontece, o pagamento existe de verdade no nosso sistema mas **não representa dinheiro cobrado**: é uma execução de teste da sua própria equipe. O folio dele sai da mesma série que os reais, então **dentro do objeto `payment` nada os distingue**.

  Esse campo reflete o domínio **do pagamento**, não o da conversa em que ele ocorreu: mesmo viajando dentro de `conversation_info`, quem o preenche é a linha do pagamento. É o correto para decidir se lançar a cobrança — e no caso raro em que os dois divirjam, quem manda para a sua contabilidade é o do pagamento.

  Se você reutiliza um único parser para as duas vias, **ramifique por `conversation_info.is_tester` antes de lançar a cobrança**. O corpo que o AI employee envia leva também `is_test: true` na raiz, mas isso está um nível acima do objeto `data` — se sua função recebe só o pacote do pagamento, não o vê.
</Warning>

<Note>
  **Esse campo tem duas formas, e o AI employee escolhe qual conforme o que escreve nele.**

  * Se não escreve nada ali, ou escreve um texto, você recebe o array direto:
    `"pagamento": [ { "payment": …, "contact_info": …, "conversation_info": … } ]`
  * Se escreve um objeto com a leitura dele, o pacote fica aninhado sob `data`:
    `"pagamento": { "tipo": "entrada", "data": [ { "payment": … } ] }`

  A segunda forma existe para que o campo leve junto o que o AI entendeu e o dado duro do
  pagamento, em vez de reparti-los em duas chaves que você tenha que cruzar por nome.

  Aceite as duas: **se o campo é um array, esses são os pagamentos; se é um objeto, estão na
  chave `data` dele**. Um parser escrito só contra a primeira forma deixa de encontrar o
  pagamento no dia em que o AI escrever um objeto ali.

  Essa chave `data` não é o objeto `data` do webhook: é o **array** desses objetos. E a mesma
  regra vale para os campos de arquivos.
</Note>

As duas vias se complementam, e por isso vale a pena ter ambas: este webhook cobre os
**desfechos** —um pagamento que sua equipe credita dias depois, quando ninguém está
conversando— e o do AI employee cobre o **contexto** do momento da conversa.

<Note>
  **Disponibilidade:** esta via está sendo habilitada por etapas **no nível da plataforma**, não
  conta por conta. Fale com a gente se quiser que avisemos quando for publicada.
</Note>

### `fail_reason`: texto livre escrito por uma pessoa

Em `payment.failed`, `fail_reason` traz o **motivo que sua equipe escreveu à mão**
ao marcar o pagamento como falho — por que não conseguiram verificá-lo. É prosa no
idioma de quem escreveu, não um código de uma lista fechada: não o use como chave
de uma tabela de traduções nem tente fazer parsing dele. O painel o exige antes de
deixar marcar o pagamento, mas **faça null-check mesmo assim**: o endpoint ainda o
aceita vazio enquanto durar a implantação desta função. Em qualquer outro evento é
`null`.

Como `payment.failed` cobre **apenas** pagamentos manuais (as falhas de gateway
não emitem webhook), você nunca vai receber um código de provedor por este campo.

<Warning>
  Trate-o como **texto de um humano, não confiável para lógica**: escape-o antes de
  renderizá-lo em HTML e não derive decisões automáticas do seu conteúdo. Pode
  trazer quebras de linha, acentos e emoji. Seu tamanho é limitado a 300
  caracteres, mas dimensione seu armazenamento pelo limite, não pelo que você vir
  nas primeiras entregas.
</Warning>

<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. Para
baixar o arquivo, peça um link assinado com a mesma API key da sua integração —
sem configuração adicional. O `{paymentUuid}` do path é `data.payment.uuid`, e
o `{fileUuid}` é o `uuid` do comprovante dentro de `data.payment.files[]`,
ambos vindos do próprio evento:

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://app.1to1ai.com/api/v1/public/{slug}/payments/{paymentUuid}/files/{fileUuid}" \
    -H "Authorization: Bearer sk_1to1_sua_api_key"
  ```
</CodeGroup>

A resposta traz a URL assinada e seu vencimento:

```json theme={null}
{
  "download_url": "https://...",
  "expires_at": "2026-07-22T15:17:38.609Z"
}
```

O `download_url` **vence em 30 minutos** (`expires_at`); se expirar antes de
você usá-lo, repita o mesmo `GET` para obter um novo.

<Warning>
  Trate-o como uma credencial temporária: vale 30 minutos, não o publique, não o
  reenvie, não o deixe em logs nem em tickets. É um link privado portador — quem
  o tiver baixa o comprovante sem precisar da sua API key — e revogar a key
  **não** invalida as URLs já emitidas.
</Warning>

O endpoint responde `404` em dois casos, ambos terminais — veja o detalhe no
[catálogo de erros](/pt/errors): `PAYMENT_NOT_FOUND` se o `paymentUuid` não
resolve para sua API key, e `FILE_NOT_FOUND` se o `fileUuid` não é um
comprovante confirmado desse pagamento.

## 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>
