# PEPINO ZAP — Documentação completa

Este arquivo reúne TODA a documentação do PEPINO ZAP em um único Markdown: os guias e a especificação OpenAPI 3.1 completa da API. É autocontido — você (ou uma IA) pode lê-lo inteiro e construir uma integração sem precisar de mais nada.

Autenticação: toda requisição usa uma API key escopada a um Projeto, no header `Authorization: Bearer pzk_live_...` (produção) ou `pzk_test_...` (teste). O modo é definido pelo host + prefixo da key (têm que casar): base URL de produção `https://api.pepinozap.com.br` (pzk_live_); modo teste `https://sandbox.pepinozap.com.br` (pzk_test_, grátis, teto 100 msgs/dia).

## O que a plataforma faz (resumo)

O PEPINO ZAP NÃO é só um transporte de envio — ele é o *system of record* das suas conversas e expõe o CRM. Com a MESMA API key (escopada ao Projeto) você tem:

- **Enviar**: texto, mídia (por URL ou upload), localização, contato, sticker, enquete, reação — `POST /v1/instances/{id}/messages*`.
- **Conectar / instâncias**: criar, listar, remover números; QR Code; status — `/v1/instances*`.
- **Receber (ao vivo)**: eventos `message.received` / `message.status` / `instance.status` por webhook (`/v1/webhooks`) ou WebSocket (`/v1/ws`).
- **Ler o histórico (system of record)**: conversas e mensagens persistidas, com a mídia hospedada por nós — `GET /v1/inbox/conversations` e `GET /v1/inbox/conversations/{id}/messages` (paginação por cursor `?before=`). Você NÃO precisa manter banco de dados nem storage de mídia próprios.
- **CRM / gestão do atendimento**: etiquetas (`/v1/inbox/labels`), atribuição e status (`/v1/inbox/conversations/{id}/assign` e `/status`), motivos de fechamento (`/v1/inbox/close-reasons`), funil de vendas (`/v1/funnels`) e relatórios (`/v1/inbox/reports/summary`).

Os endpoints de leitura (Inbox) e de CRM aceitam a mesma `pzk_` key — não são exclusivos do painel. O contrato completo de cada um está na seção OpenAPI ao final deste arquivo (procure os paths `/v1/inbox/*` e as tags Inbox, Etiquetas, Justificativas, Funil, Relatorios).

# Introdução (/docs)





O PEPINO ZAP é uma **API REST** que permite conectar números de WhatsApp ao seu sistema e enviar e receber mensagens de forma programática. Você cria **instâncias**, pareia cada uma a um aparelho via **QR Code** e, a partir daí, envia mensagens de texto e mídia por requisições HTTP e recebe os eventos de entrada e de status por **webhook** ou **WebSocket**. Além do transporte, a plataforma é o **system of record** das suas conversas — ela **guarda o histórico** (mensagens + mídia hospedada; você lê pela API, sem banco próprio) e expõe uma camada de **Inbox/CRM** (etiquetas, atribuição a atendentes, funil, relatórios) pela mesma key.

Esta página é o seu mapa mental antes de ir para os guias: o que a API faz, como os recursos se organizam (Organização → Projeto → Instância), a diferença entre os modos **teste** e **produção**, como autenticar e quais pré-requisitos liberam o uso real. Se você é novo por aqui, leia até o fim — cada conceito é retomado nos guias com exemplos prontos para copiar.

<Callout type="info">
  **Integrando com auxílio de IA?** Baixe toda a documentação — guias e a especificação OpenAPI
  completa — em um [único arquivo Markdown](/llms-full.md). É autocontido: cole o conteúdo em uma IA
  e ela tem tudo o que precisa para montar a integração, sem você copiar página por página.
</Callout>

## O que você consegue fazer [#o-que-você-consegue-fazer]

Com uma única API key você cobre todo o ciclo de uma conversa de WhatsApp — do envio à gestão do atendimento:

1. **Conectar um número** — crie uma instância e pareie-a com um aparelho lendo um QR Code (como o "Aparelhos conectados" do WhatsApp).
2. **Enviar mensagens** — texto, mídia (imagem, vídeo, áudio, documento), localização, contato, sticker, reação e enquete, por chamadas HTTP.
3. **Receber mensagens e status** — mensagens de entrada e confirmações de entrega/leitura chegam por **webhook** (entrega garantida, com retry) ou **WebSocket** (tempo real, sem endpoint público).
4. **Ler o histórico** — conversas, mensagens e mídia ficam persistidas na nossa infraestrutura; você **lista e pagina pela API** (`GET /v1/inbox/conversations` e `.../conversations/{id}/messages`, com cursor) quando precisar — sem manter banco próprio.
5. **Gerir o atendimento (CRM)** — além do transporte, a plataforma expõe um **Inbox** completo pela mesma key: **etiquetas**, **atribuição** a atendentes, **status** e **motivos de encerramento**, **funil de vendas** e **relatórios** de gestão.

Como a plataforma é o *system of record* das suas conversas, **você não precisa manter banco de dados nem storage de mídia próprios** só para o WhatsApp — ler do histórico pela API é suficiente para a maioria das integrações. Os metadados de atendimento (etiquetas, atribuição, funil) também vivem aqui. Detalhes em [Persistência e histórico](/docs/get-started/persistencia) e na [Referência da API](/docs/api) (seções **Inbox** e **Gestão**).

## Modelo de recursos [#modelo-de-recursos]

Os recursos seguem uma hierarquia de três níveis. Para integrar, na prática você trabalha sempre no nível do **Projeto** — é a ele que sua API key está amarrada.

| Recurso         | O que é                                                                                                                   | Escopo                         |
| --------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| **Organização** | A conta da sua empresa na plataforma. É o nível em que vive o cadastro e a cobrança.                                      | Contém um ou mais Projetos.    |
| **Projeto**     | A unidade de **isolamento** da integração. Agrupa suas instâncias, seus webhooks, suas conversas e a assinatura/créditos. | Contém uma ou mais Instâncias. |
| **Instância**   | Um **número de WhatsApp** conectado, pareado a um aparelho via QR Code.                                                   | Pertence a um único Projeto.   |

A **API key é escopada a um único Projeto**: toda instância, mensagem ou webhook que você cria, lista ou remove com a key pertence ao Projeto dela. A Organização é o container de conta/cobrança acima dos Projetos — ela existe, mas **não aparece nas respostas da API** (os objetos retornam `projectId`, não a organização); você não precisa manipulá-la para integrar.

<Callout type="info">
  Quer **isolar ambientes ou clientes** (ex.: um Projeto por cliente final, ou um Projeto para
  staging)? Crie Projetos separados e use a key de cada um. Como o Projeto é a fronteira de
  isolamento, dados de um nunca vazam para o outro.
</Callout>

A geração de keys é **self-service**: você se cadastra, confirma o e-mail e gera a key no painel. A key crua é exibida **uma única vez** no momento da criação — guarde-a em local seguro, pois não há como recuperá-la depois (se perder, revogue e gere outra). O passo a passo está em [Autenticação](/docs/get-started/authentication).

### Glossário rápido [#glossário-rápido]

Termos que aparecem em toda a documentação:

| Termo                     | Significado                                                                                                                                       |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Instância**             | Um número de WhatsApp conectado dentro do seu Projeto. Você envia mensagens "por uma instância".                                                  |
| **`status` da instância** | Em que ponto da conexão a instância está: `DISCONNECTED`, `CONNECTING`, `QR_PENDING`, `CONNECTED`, `BANNED`, `ERROR`. Só envie em `CONNECTED`.    |
| **QR Code**               | A imagem que você renderiza para o usuário escanear no app do WhatsApp (Aparelhos conectados) e parear o número.                                  |
| **JID**                   | O identificador interno do WhatsApp para um destino, no formato `5511999999999@s.whatsapp.net`. O campo `to` aceita ele ou só os dígitos (E.164). |
| **Webhook / WebSocket**   | Os dois caminhos para **receber** eventos (mensagens de entrada e status). Webhook chama o seu endpoint; WebSocket é uma conexão que você abre.   |
| **Créditos da API**       | Saldo pré-pago consumido a cada mensagem enviada **pela API** (envios pelo painel não consomem).                                                  |

## Modos: teste e produção [#modos-teste-e-produção]

Uma **única conta**, com dois **modos** — igual ao test/live do Stripe. O modo é determinado pela &#x2A;*base URL (host)** da API: hosts que começam com `sandbox.` operam em **teste**; `api.` opera em **produção**. Os dados são **totalmente isolados** entre os modos — instâncias, conversas e webhooks de teste nunca aparecem em produção, e vice-versa.

| Modo         | Base URL                           | Prefixo da API key | Cobrança                |
| ------------ | ---------------------------------- | ------------------ | ----------------------- |
| **Teste**    | `https://sandbox.pepinozap.com.br` | `pzk_test_`        | Grátis, com teto diário |
| **Produção** | `https://api.pepinozap.com.br`     | `pzk_live_`        | Exige plano ativo       |

**É o host que decide o modo, não a key** — mas a key tem que **casar com o host**. Uma key de produção (`pzk_live_`) usada no host de teste (`sandbox.*`), ou o contrário, é rejeitada com `401`. Use sempre a key do mesmo modo da base URL.

```bash
# pzk_live_ no host de teste → 401 (key do modo errado)
curl https://sandbox.pepinozap.com.br/v1/instances \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx"
```

```json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "chave de API inválida para este ambiente/modo"
  }
}
```

<Callout type="info">
  **Use o modo Teste para construir.** Em teste, criar instâncias e enviar mensagens **não exige
  plano** — você valida a integração inteira de graça. Para evitar que o teste vire "produção de
  graça", há um **teto de 100 mensagens/dia por Projeto** (enviadas + recebidas; acima disso,
  `429`). Quando estiver pronto para volume real, troque a base URL para `api.*` e a key para
  `pzk_live_`, e assine um plano. Veja [Modos: teste e
  produção](/docs/get-started/authentication#modos-teste-e-producao).
</Callout>

## Autenticação em uma frase [#autenticação-em-uma-frase]

Envie a API key no header `Authorization` com o esquema `Bearer` em **todas** as chamadas REST:

```bash
curl https://api.pepinozap.com.br/v1/instances \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx"
```

As **únicas exceções** são os transportes que o navegador abre sem poder definir headers — o **stream SSE de QR Code** (`GET /v1/instances/{id}/qr`) e o **WebSocket de eventos** (`/v1/ws`). Nesses dois casos, a key vai na query string como `?token=<API_KEY>`. Em qualquer outro endpoint, use sempre o header `Authorization`. O detalhamento (inclusive o token efêmero de 2 minutos para não expor a key na URL do SSE) está em [Autenticação](/docs/get-started/authentication).

## Pré-requisitos para operar (402 e 429) [#pré-requisitos-para-operar-402-e-429]

Ter uma API key válida **não basta** para enviar de verdade. As ações de **uso real** — conectar um número e enviar mensagens — passam por gates de negócio; as ações de **leitura** e o **pareamento por QR Code** ficam sempre liberadas.

| Ação                                                                           | Pré-requisito                                                                       |
| ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- |
| Criar instância (`POST /v1/instances`)                                         | Plano ativo (em produção)                                                           |
| Enviar mensagem (texto, mídia, localização, contato, sticker, reação, enquete) | Plano ativo (em produção); em projetos modo API, também consome 1 crédito por envio |
| Listar / detalhar instância, parear via QR, marcar como lida                   | Nenhum — sempre liberado                                                            |

* **`402` `PAYMENT_REQUIRED`** — em **produção**, o Projeto não tem **assinatura ativa**, ou um Projeto no modo API ficou **sem créditos**. Distinga os dois casos pela `message`. Resolução: assinar ou recarregar em **Meu plano**, no painel. Em **teste**, esses gates são liberados (não ocorre `402`).
* **`429` `RATE_LIMITED`** — **apenas em teste**: o teto de 100 mensagens/dia por Projeto foi atingido. Em produção não ocorre.

<Callout type="warn">
  `402` é um **gate de negócio**, não um erro transitório — **não repita a chamada** em loop. A ação
  só volta a funcionar após assinar ou recarregar; o seu código não muda. Detalhes e exemplos de
  payload em [Plano ativo (erro 402)](/docs/get-started/authentication#plano-ativo-erro-402).
</Callout>

## Como começar [#como-começar]

Siga os guias nesta ordem — eles formam o caminho mínimo de ponta a ponta:

1. **[Quick start](/docs/get-started/quick-start)** — o fluxo completo num só lugar: obter a key, criar uma instância, parear via QR Code e enviar a primeira mensagem.
2. **[Autenticação](/docs/get-started/authentication)** — gerar e usar a API key no header `Authorization: Bearer`, os modos teste/produção e o fallback `?token=`.
3. **[Sua primeira instância](/docs/get-started/first-instance)** — o ciclo de status de `DISCONNECTED` até `CONNECTED`, o pareamento por QR (polling, recomendado, e SSE) e as configurações da instância.

<Callout type="warn">
  **Envie só quando a instância estiver `CONNECTED`.** Enviar para uma instância
  ainda não conectada &#x2A;*também retorna `202`** (`{ "queued": true }`), mas a
  mensagem **não sai** e **não há evento de falha**. Antes de enviar, confirme
  `GET /v1/instances/{id}` (`status === "CONNECTED"`) ou o `qr/poll`
  (`connected === true`).
</Callout>

## Referência rápida de erros [#referência-rápida-de-erros]

Toda resposta de erro segue o mesmo envelope. Use o campo `code` (estável) no seu tratamento de erros, não a `message` (apenas legível).

```json
{
  "error": {
    "code": "BAD_REQUEST",
    "message": "Body inválido"
  }
}
```

| Status | `code`             | Quando ocorre                                                                                   |
| ------ | ------------------ | ----------------------------------------------------------------------------------------------- |
| `400`  | `BAD_REQUEST`      | Corpo inválido ou campo obrigatório faltando.                                                   |
| `401`  | `UNAUTHORIZED`     | Key ausente, inválida, revogada, expirada **ou** do modo errado para o host.                    |
| `402`  | `PAYMENT_REQUIRED` | Sem plano ativo **ou** sem créditos da API (só em produção).                                    |
| `403`  | `FORBIDDEN`        | Projeto inativo ou sem permissão.                                                               |
| `404`  | `NOT_FOUND`        | Recurso não encontrado no Projeto/modo da key.                                                  |
| `409`  | `CONFLICT`         | Ao criar instância: cota de números do plano atingida. Ao enviar: instância `BANNED`/`ERROR`.   |
| `429`  | `RATE_LIMITED`     | **Apenas em teste:** teto diário do Projeto atingido (100 mensagens/dia, enviadas + recebidas). |

## Próximos passos [#próximos-passos]

* **[Referência da API](/docs/api)** — endpoints de instâncias e mensagens (texto, mídia, localização, contato, sticker, reação, enquete), configurações da instância, payloads de requisição e resposta, e erros esperados.
* **[Inbox e Gestão (CRM)](/docs/api)** — ler conversas e mensagens persistidas (system of record) com mídia hospedada, e gerir o atendimento: etiquetas, atribuição, status/motivos, funil e relatórios — tudo pela mesma key.
* **[Persistência e histórico](/docs/get-started/persistencia)** — como a plataforma guarda suas conversas e como ler o histórico pelo Inbox sem manter banco próprio.
* **[Webhooks](/docs/webhooks)** — os dez eventos (mensagem, instância, chamada, grupo, contato e etiqueta), envelope de entrega e verificação de assinatura.
* **[WebSocket](/docs/webhooks/websocket)** — os mesmos eventos em tempo real por conexão persistente, sem endpoint público.


# Visão geral (/docs/api)





Esta é a referência da API REST do PEPINO ZAP. Com ela o seu sistema conecta números de WhatsApp (instâncias) e envia mensagens de forma programática. Cada endpoint da referência traz exemplos de requisição em várias linguagens e um playground "Try it" para testar a chamada direto na página.

Esta página de visão geral reúne as **convenções comuns a toda a API** — as regras que valem para qualquer endpoint: como autenticar, em que ambiente você está (teste ou produção), como escrever o destinatário, como o sucesso e o erro são representados e por que o envio é assíncrono. Leia-a uma vez antes de mergulhar nos endpoints; ela evita a maioria dos tropeços de integração.

<Callout type="info">
  Se você ainda não gerou uma API key nem conectou um número, comece pelos
  guias passo a passo: [Autenticação](/docs/get-started/authentication) (criar a
  conta e gerar a key) e [Sua primeira instância](/docs/get-started/first-instance)
  (conectar um número via QR Code). Esta página é a referência das convenções;
  aqueles guias são o caminho guiado.
</Callout>

## Antes de começar: os três pré-requisitos [#antes-de-começar-os-três-pré-requisitos]

Para uma chamada de uso real (criar instância ou enviar mensagem) ser aceita, três coisas precisam estar certas ao mesmo tempo:

1. **API key válida** no header `Authorization` (veja [Autenticação](#autenticacao)). Sem ela: `401`.
2. **Host correto para o modo da key** — o host `sandbox.*` é Teste e exige `pzk_test_`; o host `api.*` é Produção e exige `pzk_live_`. Key e host têm que casar. Se não casarem: `401`.
3. **Plano ativo** no Projeto (só em Produção) — criar instâncias e enviar mensagens exigem assinatura ativa. Sem ela: `402`. No modo Teste é grátis (com teto diário).

Leitura (listar/detalhar), pareamento por QR Code e "marcar como lida" só exigem os pré-requisitos 1 e 2 — **não** dependem de plano.

## Base URL e o prefixo `/v1` [#base-url-e-o-prefixo-v1]

Todas as rotas vivem sob o prefixo `/v1`. A URL completa de um endpoint é sempre `<BASE_URL>/v1/<caminho>`. A base URL define em qual **modo** (Teste ou Produção) você está operando:

| Modo                | Base URL                           | Prefixo da key | Cobrança                                          |
| ------------------- | ---------------------------------- | -------------- | ------------------------------------------------- |
| **Teste** (sandbox) | `https://sandbox.pepinozap.com.br` | `pzk_test_`    | grátis, com teto de 100 mensagens/dia por Projeto |
| **Produção** (live) | `https://api.pepinozap.com.br`     | `pzk_live_`    | exige plano ativo                                 |

É o **host que decide o modo**: qualquer host que comece com `sandbox` é Teste; os demais (`api.*`) são Produção. A API key **não** decide o modo — ela apenas precisa ser coerente com o host. Por isso uma `pzk_test_` chamando `api.*` (ou uma `pzk_live_` chamando `sandbox.*`) é rejeitada com `401`, mesmo sendo uma key real e válida.

```bash
# Teste (sandbox) — key pzk_test_
curl https://sandbox.pepinozap.com.br/v1/instances \
  -H "Authorization: Bearer pzk_test_xxxxxxxxxxxxxxxx"

# Produção (live) — key pzk_live_
curl https://api.pepinozap.com.br/v1/instances \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx"
```

<Callout type="info">
  Os dois modos são **100% isolados**: instâncias, conversas e webhooks criados
  em Teste nunca aparecem em Produção e vice-versa. Inclusive um `id` de
  instância de um modo, usado no outro modo, retorna `404` (não vaza entre
  ambientes). Valide a integração inteira em `sandbox.*` sem custo e, quando
  estiver pronto, troque a base URL para `api.*` e a key para `pzk_live_` — o
  resto do código permanece igual.
</Callout>

## Autenticação [#autenticação]

Toda chamada envia a API key no header `Authorization` com o esquema `Bearer`:

```
Authorization: Bearer <API_KEY>
```

A key é **escopada a um Projeto**: todas as instâncias e mensagens criadas ou listadas com ela pertencem a esse Projeto. A geração é self-service no painel (Integrações → API Keys), e a key crua aparece **uma única vez** na criação. Detalhes de cadastro, do toggle Teste/Produção e de revogação estão em [Autenticação](/docs/get-started/authentication).

**Exceção — SSE de QR Code.** O único endpoint que **não** usa o header é o stream de QR Code via Server-Sent Events (`GET /v1/instances/{id}/qr`), porque o `EventSource` do navegador não envia headers customizados. Nele a key vai pela query string, `?token=<API_KEY>`. Recomenda-se emitir um token efêmero (`POST /v1/instances/{id}/qr-token`, válido \~2 min) para não expor a key crua na URL. Todos os demais endpoints — inclusive o **polling** de QR (`/qr/poll`) — usam o header normalmente. Veja [Autenticação → Fallback `?token=`](/docs/get-started/authentication#fallback-token-apenas-para-sse) e [Sua primeira instância → Parear via QR Code](/docs/get-started/first-instance#parear-via-qr-code).

## Formato do campo `to` [#formato-do-campo-to]

Todos os endpoints de mensagem identificam o destinatário pelo campo `to`. Ele aceita **três formatos**, todos equivalentes — use o que for mais conveniente:

| Formato                        | Exemplo                        | Observação                       |
| ------------------------------ | ------------------------------ | -------------------------------- |
| Apenas dígitos (E.164 sem `+`) | `5511999999999`                | Forma mais simples; recomendada. |
| E.164 com `+`                  | `+5511999999999`               | O `+` é aceito.                  |
| JID completo                   | `5511999999999@s.whatsapp.net` | Forma "nativa" do WhatsApp.      |

O número deve incluir **código do país** e **DDD**, sem espaços, traços ou parênteses. Para o Brasil, o código do país é `55`.

<Callout type="warn">
  A API **não verifica**, no momento do envio, se o número existe no WhatsApp —
  a validação só confirma que o campo `to` não está vazio. Um `to` com número
  inexistente é aceito e enfileirado (resposta `202`), mas a mensagem
  simplesmente não é entregue, e **não** há evento de falha por número inválido.
  Garanta que o `to` está correto antes de enviar. Para envio a **grupos**, use
  o JID de grupo recebido nos webhooks; este guia trata de conversas individuais
  (1:1).
</Callout>

## Convenções de requisição e resposta [#convenções-de-requisição-e-resposta]

* **Método e corpo.** Endpoints de escrita usam `POST`/`PATCH`/`DELETE`; os de leitura usam `GET`. Requisições com corpo JSON exigem o header `Content-Type: application/json` (a exceção é o upload de mídia por arquivo, que é `multipart/form-data`).
* **Formato.** Pedidos e respostas são JSON (`UTF-8`), salvo o SSE de QR (`text/event-stream`) e o upload de mídia (multipart).
* **Códigos de sucesso por tipo de operação:**

| Operação                               | Sucesso          | Corpo da resposta                            |
| -------------------------------------- | ---------------- | -------------------------------------------- |
| Criar instância (`POST /v1/instances`) | `201 Created`    | A instância criada (objeto completo).        |
| Detalhar / listar / atualizar settings | `200 OK`         | A instância (ou a lista).                    |
| Remover instância (`DELETE`)           | `204 No Content` | Sem corpo.                                   |
| Enviar qualquer tipo de mensagem       | `202 Accepted`   | Confirmação de enfileiramento (veja abaixo). |

* **Confirmação de envio.** Os endpoints de envio respondem com um objeto curto que confirma o **enfileiramento**, não a entrega:

```json
{
  "queued": true,
  "to": "5511999999999"
}
```

Endpoints de mídia e sticker incluem também o campo `type` (ex.: `"type": "image"`). O `to` ecoado é exatamente o que você enviou.

## Envio assíncrono (202) e idempotência [#envio-assíncrono-202-e-idempotência]

O envio de mensagens é **assíncrono**. A resposta `202 Accepted` confirma apenas que a mensagem foi **enfileirada** — não que foi entregue nem lida. O processamento real (envio ao WhatsApp) ocorre logo em seguida, em segundo plano. A confirmação de **entrega e leitura** chega depois pelo evento `message.status` no [webhook](/docs/webhooks).

<Callout type="warn">
  **A instância precisa estar `CONNECTED` para a mensagem sair.** Enviar para
  uma instância que não está conectada (por exemplo, `DISCONNECTED` ou aguardando
  QR) **também** retorna `202` — mas a mensagem fica em espera e **não** é
  entregue, sem gerar evento de falha. Confirme `status == CONNECTED` (via
  `GET /v1/instances/{id}` ou `GET /v1/instances/{id}/qr/poll` com
  `connected == true`) **antes** de enviar. Instâncias em estado terminal
  (`BANNED`/`ERROR`) são bloqueadas já no envio, com `409` (veja a tabela de
  erros).
</Callout>

**Idempotência.** Os endpoints de envio **não são idempotentes**: cada chamada enfileira um novo envio. Se a sua requisição falhar com erro de rede ou timeout **depois** de a API ter aceitado o job, repeti-la pode resultar em mensagem duplicada. Para reenviar com segurança, controle a deduplicação no seu lado (por exemplo, só repita quando tiver certeza de que a chamada anterior **não** retornou `202`). Já os erros `402` (plano/créditos), `401`, `403`, `404`, `409` e `429` são respostas de negócio/validação — **não** os repita em loop: corrija a causa (assinar, recarregar, conectar a instância, corrigir a key) e tente de novo.

## Envelope de erro [#envelope-de-erro]

Toda resposta de erro segue um envelope único e estável:

```json
{
  "error": {
    "code": "BAD_REQUEST",
    "message": "campos to e body são obrigatórios"
  }
}
```

O campo `code` identifica a **categoria** de forma estável — programe o seu tratamento de erros em cima dele. O campo `message` é apenas legível por humanos (em português) e pode mudar; **não** faça parsing nem `switch` sobre a `message`, com uma exceção prática anotada na tabela (o `402` de plano vs. créditos).

| Status | `code`             | Significado                                                                                                                                                                                          |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `BAD_REQUEST`      | Corpo JSON inválido ou campo obrigatório faltando/ inválido (ex.: `to` vazio, `type` de mídia inválido).                                                                                             |
| `401`  | `UNAUTHORIZED`     | API key ausente, inválida, **ou** do modo errado para o host (`pzk_live_` no sandbox, ou vice-versa).                                                                                                |
| `402`  | `PAYMENT_REQUIRED` | Sem plano ativo **ou** sem créditos da API (ver [abaixo](#pre-requisito-plano-ativo-402)). Distinga os dois casos pela `message`. **Não** repita a chamada — exige ação humana (assinar/recarregar). |
| `403`  | `FORBIDDEN`        | Projeto inativo ou sem permissão.                                                                                                                                                                    |
| `404`  | `NOT_FOUND`        | Recurso não encontrado no Projeto da key — inclui o caso de a instância existir, mas pertencer a **outro modo** (teste/produção) ou a outro Projeto.                                                 |
| `409`  | `CONFLICT`         | Ao **criar** instância: cota de números do plano atingida. Ao **enviar**: instância não-operacional (`BANNED` ou `ERROR`).                                                                           |
| `429`  | `RATE_LIMITED`     | **Apenas no sandbox:** teto diário do Projeto atingido (100 mensagens/dia por Projeto, enviadas + recebidas; o contador zera à meia-noite no horário de São Paulo). Em produção não ocorre.          |
| `5xx`  | `INTERNAL_ERROR`   | Falha inesperada do servidor. Em geral é transitória — pode ser repetida com recuo (backoff).                                                                                                        |

<Callout type="info">
  O `404` é deliberadamente "cego" ao motivo: tentar acessar uma instância de
  outro modo (ex.: um `id` de produção usando a base URL de sandbox) ou de outro
  Projeto retorna `404`, e não um `403`. Isso evita vazar a existência de
  recursos entre Projetos e entre ambientes. Se um `id` que você "tem certeza"
  que existe dá `404`, confira primeiro se a base URL e o prefixo da key batem
  com o modo em que a instância foi criada.
</Callout>

## Pré-requisito: plano ativo (402) [#pré-requisito-plano-ativo-402]

Além da API key, o Projeto precisa de uma **assinatura ativa** para **criar instâncias** e **enviar mensagens** em produção. Sem plano ativo, esses endpoints retornam `402` (`PAYMENT_REQUIRED`):

```json
{
  "error": {
    "code": "PAYMENT_REQUIRED",
    "message": "É necessário um plano ativo para usar este recurso. Assine em \"Meu plano\"."
  }
}
```

Endpoints que **exigem** plano ativo (em produção):

* `POST /v1/instances` — criar instância
* `POST /v1/instances/{id}/messages` e todos os demais envios (`/media`, `/media-upload`, `/location`, `/contact`, `/sticker`, `/reaction`, `/poll`)

Endpoints de **leitura** (listar/detalhar instâncias), o **pareamento por QR Code** (SSE, polling e emissão de token) e o **marcar como lida** (`/messages/read`) continuam liberados mesmo sem plano.

**Modo Teste é grátis.** Em `sandbox.*` a assinatura **não** é exigida — você só esbarra no teto diário (`429`). Use o sandbox para validar a integração inteira sem custo.

**Créditos da API (também `402`).** Projetos no modo **API** consomem créditos pré-pagos a cada mensagem **enviada pela API key** (envios pelo painel/inbox **não** consomem). Quando o saldo zera, os endpoints de envio retornam o **mesmo** `402` `PAYMENT_REQUIRED`, mas com a `message` indicando créditos insuficientes (ex.: `"créditos da API insuficientes — recarregue em Meu plano"`). É por isso que, para o `402`, a `message` é o único sinal que distingue os dois casos: **falta de plano** versus **falta de créditos**.

A resolução de ambos (assinar ou recarregar) é em **Meu plano**, no painel — é um gate de negócio que exige ação humana, **não** repita a chamada. Projetos de cortesia (isentos, marcados pelo time PEPINO) nunca recebem este erro. Detalhes em [Autenticação → Plano ativo](/docs/get-started/authentication#plano-ativo-erro-402).

## Como navegar a referência [#como-navegar-a-referência]

Os endpoints abaixo agrupam-se em quatro áreas. Cada link leva à página do endpoint, com todos os campos, exemplos por linguagem e o playground "Try it".

### Instâncias [#instâncias]

Conectar e gerenciar os números de WhatsApp do Projeto.

| Operação                       | Método e caminho                    | Referência                                                            |
| ------------------------------ | ----------------------------------- | --------------------------------------------------------------------- |
| Listar instâncias              | `GET /v1/instances`                 | [listInstances](/docs/api/instancias/listInstances)                   |
| Criar instância                | `POST /v1/instances`                | [createInstance](/docs/api/instancias/createInstance)                 |
| Detalhar instância             | `GET /v1/instances/{id}`            | [getInstance](/docs/api/instancias/getInstance)                       |
| Atualizar configurações        | `PATCH /v1/instances/{id}/settings` | [updateInstanceSettings](/docs/api/instancias/updateInstanceSettings) |
| Remover instância              | `DELETE /v1/instances/{id}`         | [deleteInstance](/docs/api/instancias/deleteInstance)                 |
| Parear via QR Code (SSE)       | `GET /v1/instances/{id}/qr`         | [streamInstanceQR](/docs/api/instancias/streamInstanceQR)             |
| Parear via QR Code (polling)   | `GET /v1/instances/{id}/qr/poll`    | [pollInstanceQR](/docs/api/instancias/pollInstanceQR)                 |
| Token efêmero para o SSE de QR | `POST /v1/instances/{id}/qr-token`  | [createQrToken](/docs/api/instancias/createQrToken)                   |

O ciclo de vida da instância e o pareamento estão detalhados em [Sua primeira instância](/docs/get-started/first-instance).

### Mensagens [#mensagens]

Todos os envios são por `POST /v1/instances/{id}/messages/...`, respondem `202` e são assíncronos.

| Tipo                      | Caminho                          | Referência                                                     |
| ------------------------- | -------------------------------- | -------------------------------------------------------------- |
| Texto                     | `POST .../messages`              | [sendTextMessage](/docs/api/mensagens/sendTextMessage)         |
| Mídia (por URL)           | `POST .../messages/media`        | [sendMediaMessage](/docs/api/mensagens/sendMediaMessage)       |
| Mídia (upload de arquivo) | `POST .../messages/media-upload` | [sendMediaUpload](/docs/api/mensagens/sendMediaUpload)         |
| Localização               | `POST .../messages/location`     | [sendLocationMessage](/docs/api/mensagens/sendLocationMessage) |
| Contato                   | `POST .../messages/contact`      | [sendContactMessage](/docs/api/mensagens/sendContactMessage)   |
| Sticker                   | `POST .../messages/sticker`      | [sendStickerMessage](/docs/api/mensagens/sendStickerMessage)   |
| Reação                    | `POST .../messages/reaction`     | [sendReactionMessage](/docs/api/mensagens/sendReactionMessage) |
| Enquete                   | `POST .../messages/poll`         | [sendPollMessage](/docs/api/mensagens/sendPollMessage)         |
| Marcar como lida          | `POST .../messages/read`         | [markMessagesRead](/docs/api/mensagens/markMessagesRead)       |

Notas úteis: em **mídia**, o campo `type` aceita `image`, `video`, `audio` ou `document`, e um áudio com `ptt: true` vira nota de voz; em **reação**, `emoji` vazio remove a reação; em **enquete**, `options` deve ter de 2 a 12 alternativas. Os campos exatos de cada tipo estão na página da referência correspondente.

### Webhooks [#webhooks]

O integrador **recebe** eventos (mensagem recebida, status de envio e status da instância) em uma URL própria, cadastrada **via API** (self-service):

| Operação          | Método e caminho           | Referência                                        |
| ----------------- | -------------------------- | ------------------------------------------------- |
| Cadastrar webhook | `POST /v1/webhooks`        | [createWebhook](/docs/api/webhooks/createWebhook) |
| Listar webhooks   | `GET /v1/webhooks`         | [listWebhooks](/docs/api/webhooks/listWebhooks)   |
| Remover webhook   | `DELETE /v1/webhooks/{id}` | [deleteWebhook](/docs/api/webhooks/deleteWebhook) |

O formato do payload, a assinatura HMAC e a lista de eventos estão em [Webhooks](/docs/webhooks). Para receber eventos em tempo real por conexão persistente em vez de HTTP, veja também o [WebSocket](/docs/webhooks/websocket).

### Inbox (CRM) [#inbox-crm]

Consultar as conversas e mensagens armazenadas pela plataforma e gerir o atendimento (status, atribuição):

| Operação                        | Método e caminho                            | Referência                                                       |
| ------------------------------- | ------------------------------------------- | ---------------------------------------------------------------- |
| Listar conversas                | `GET /v1/inbox/conversations`               | [listInboxConversations](/docs/api/inbox/listInboxConversations) |
| Detalhar conversa               | `GET /v1/inbox/conversations/{id}`          | [getInboxConversation](/docs/api/inbox/getInboxConversation)     |
| Listar mensagens da conversa    | `GET /v1/inbox/conversations/{id}/messages` | [listInboxMessages](/docs/api/inbox/listInboxMessages)           |
| Mudar status (encerrar/reabrir) | `POST /v1/inbox/conversations/{id}/status`  | [setConversationStatus](/docs/api/inbox/setConversationStatus)   |
| Atribuir a um atendente         | `POST /v1/inbox/conversations/{id}/assign`  | [assignConversation](/docs/api/inbox/assignConversation)         |

### Gestão (CRM) [#gestão-crm]

As ferramentas de gestão do atendimento. O modelo de dados e o passo a passo de cada uma estão no guia [CRM e gestão](/docs/crm):

| Área           | O que faz                                      | Referência                                                                                 |
| -------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------ |
| Relatórios     | Métricas agregadas do atendimento              | [getReportsSummary](/docs/api/relatorios/getReportsSummary) · [guia](/docs/crm/relatorios) |
| Etiquetas      | Classificar e filtrar conversas                | [listLabels](/docs/api/etiquetas/listLabels) · [guia](/docs/crm/etiquetas-encerramento)    |
| Justificativas | Motivos de encerramento                        | [listCloseReasons](/docs/api/justificativas/listCloseReasons)                              |
| Fluxos         | Construtor de chatbot por número               | [listFlows](/docs/api/fluxos/listFlows) · [guia](/docs/crm/fluxos)                         |
| Funil          | Funil cujas etapas são regras sobre a conversa | [listFunnels](/docs/api/funil/listFunnels) · [guia](/docs/crm/funil)                       |

## Próximos passos [#próximos-passos]

* [Autenticação](/docs/get-started/authentication) — gerar a API key e entender os modos teste/produção.
* [Sua primeira instância](/docs/get-started/first-instance) — conectar um número via QR Code.
* [Enviar uma mensagem de texto](/docs/api/mensagens/sendTextMessage) — o primeiro envio após o pareamento.
* [Webhooks](/docs/webhooks) — receber eventos de mensagens e de status da instância.
* [CRM e gestão](/docs/crm) — relatórios, etiquetas, encerramento, equipe e o construtor de chatbot.


# Disparo em massa (campanhas) (/docs/campanhas)





Uma **campanha** envia uma mensagem-template para muitos contatos respeitando uma **cadência anti-ban**: a plataforma controla *quando* e *em que ritmo* cada mensagem sai, em vez de despejar tudo de uma vez. Você cria a campanha, configura a cadência, dispara, e acompanha o status de cada destinatário.

<Callout type="warn">
  Disparo em massa pode levar ao **banimento** do número pela Meta. A cadência reduz o risco, mas
  não o elimina: use listas com **opt-in**, conteúdo não-spam e volume moderado. O risco é do
  titular da conta. Criar/editar exige o add-on **Disparo em massa**; disparar/retomar exige também
  assinatura ativa (no modo teste é liberado para validar a integração).
</Callout>

## Ciclo de vida [#ciclo-de-vida]

```text
DRAFT ──start──> RUNNING ──pause──> PAUSED ──resume──> RUNNING ──> COMPLETED
                    │                  ▲
                    └── auto-pausa ────┘  (N falhas/inválidos consecutivos)
```

* **DRAFT** — criada, nada foi enviado. Edite e adicione/remova destinatários à vontade.
* **RUNNING** — a engine dispara em background, respeitando a cadência.
* **PAUSED** — pausada (manualmente ou por auto-pausa). Retome quando quiser.
* **COMPLETED** / **CANCELLED** — encerrada; o histórico permanece.

## Tutorial rápido (curl) [#tutorial-rápido-curl]

Fluxo completo de ponta a ponta — criar, (opcional) assinar os eventos, disparar e acompanhar. Comece pelo **sandbox** (liberado para validar a integração); em produção, troque o host e o prefixo da chave.

```bash
# Ambiente. Sandbox para testar; produção quando estiver pronto.
BASE="https://sandbox.pepinozap.com.br"   # produção: https://api.pepinozap.com.br
TOKEN="pzk_test_xxx"                       # produção: pzk_live_xxx
AUTH="Authorization: Bearer $TOKEN"
```

**1. Descubra a instância (número conectado) que vai disparar:**

```bash
curl -s "$BASE/v1/instances" -H "$AUTH"
# pegue o "id" de uma instância com status CONNECTED -> INSTANCE
INSTANCE="cm5x8a9k20001abc123def456"
```

**2. (Opcional, recomendado) Assine os eventos em tempo real.** Cadastre um webhook que recebe `campaign.status` por destinatário (guarde o `secret` para validar a assinatura):

```bash
curl -s "$BASE/v1/webhooks" -H "$AUTH" -H 'Content-Type: application/json' -d '{
  "name": "campanhas",
  "url": "https://seusistema.com/webhooks/pepino",
  "events": ["campaign.status"]
}'
```

**3. Crie a campanha** (volta `DRAFT`). Capture o `id` retornado:

```bash
curl -s "$BASE/v1/campaigns" -H "$AUTH" -H 'Content-Type: application/json' -d "{
  \"name\": \"Promo Junho\",
  \"instanceId\": \"$INSTANCE\",
  \"template\": \"Olá {{nome}}! Aproveite {{desconto}} só hoje.\",
  \"recipients\": [
    { \"phone\": \"+55 11 99999-0001\", \"name\": \"Maria\", \"vars\": { \"desconto\": \"20% off\" } },
    \"5511999990002\"
  ],
  \"cadence\": {
    \"warmup\":   { \"base\": 20, \"factor\": 1.2, \"cap\": 200 },
    \"window\":   { \"start\": \"08:00\", \"end\": \"23:00\", \"tz\": \"America/Sao_Paulo\" },
    \"pacing\":   { \"baseSeconds\": 60, \"jitterPct\": 0.2 },
    \"autoPause\":{ \"consecutiveThreshold\": 5 },
    \"validateNumbers\": true
  }
}"
# -> { "id": "camp_...", "totalRecipients": 2 }
CAMPAIGN="camp_..."
```

**4. Dispare.** A engine assume em background (responde `202` na hora):

```bash
curl -s -X POST "$BASE/v1/campaigns/$CAMPAIGN/start" -H "$AUTH"
# -> { "started": true }
```

**5. Acompanhe.** Pelos eventos `campaign.status` (passo 2) ou por polling:

```bash
# Agregado (totais por status + runtime: dia, teto de hoje, próximo envio)
curl -s "$BASE/v1/campaigns/$CAMPAIGN" -H "$AUTH"

# Por destinatário (paginado; filtre por status)
curl -s "$BASE/v1/campaigns/$CAMPAIGN/recipients?status=FAILED&page=1" -H "$AUTH"
```

**6. Controle quando precisar:**

```bash
curl -s -X POST "$BASE/v1/campaigns/$CAMPAIGN/pause"  -H "$AUTH"   # pausar
curl -s -X POST "$BASE/v1/campaigns/$CAMPAIGN/resume" -H "$AUTH"   # retomar
curl -s -X POST "$BASE/v1/campaigns/$CAMPAIGN/complete" -H "$AUTH" # encerrar
```

<Callout type="info">
  No **sandbox** o recurso é liberado para validar a integração (com um teto diário por projeto). Em
  **produção**, criar/editar exige o add-on “Disparo em massa” e disparar/retomar exige também
  assinatura ativa — sem isso a API responde `402`.
</Callout>

## Criando uma campanha [#criando-uma-campanha]

`POST /v1/campaigns` cria em **DRAFT**. A mensagem é um **template** com variáveis por destinatário, e cada destinatário pode ser só o número ou um objeto com `phone`, `name` e `vars`:

```json
{
  "name": "Promo Junho",
  "instanceId": "cm5x8a9k20001abc123def456",
  "template": "Olá {{nome}}! Aproveite {{desconto}} só hoje.",
  "recipients": [
    { "phone": "+55 11 99999-0001", "name": "Maria", "vars": { "desconto": "20% off" } },
    "5511999990002"
  ],
  "cadence": {
    "warmup": { "base": 20, "factor": 1.2, "cap": 200 },
    "window": { "start": "08:00", "end": "23:00", "tz": "America/Sao_Paulo" },
    "pacing": { "baseSeconds": 60, "jitterPct": 0.2 },
    "autoPause": { "consecutiveThreshold": 5 },
    "validateNumbers": true
  }
}
```

* **Variáveis** — `{{nome}}` vem do `name`; qualquer `{{chave}}` vem de `vars.chave`. Variável ausente vira string vazia.
* **Destinatários** — números em qualquer formato (mantemos só os dígitos); vazios e duplicados são descartados. Mínimo 1, máximo **10.000** por campanha.
* **`cadence` é opcional** — campos omitidos usam os defaults abaixo.

## A cadência anti-ban [#a-cadência-anti-ban]

| Recurso            | Campo                            | O que faz                                                                                                                                           |
| ------------------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Warmup**         | `warmup.{base,factor,cap}`       | Teto diário **crescente**: `cap_do_dia = min(cap, ceil(base * factor^dia))`. Ex.: base 20, fator 1.2, teto 200 — começa em 20/dia e cresce até 200. |
| **Janela horária** | `window.{start,end,tz}`          | Só envia no intervalo (no timezone). Fora dele, **pausa e retoma** na próxima abertura.                                                             |
| **Ritmo (pacing)** | `pacing.{baseSeconds,jitterPct}` | Intervalo entre envios = `baseSeconds` ± `jitterPct` (aleatório, sem rajada). Ex.: 60s ±20%.                                                        |
| **Auto-pausa**     | `autoPause.consecutiveThreshold` | Pausa a campanha após **N falhas/inválidos consecutivos** (default 5). O motivo fica em `pausedReason`.                                             |
| **Validação**      | `validateNumbers`                | Confere no WhatsApp **antes** de enviar; número sem conta vira `invalid` e **não consome** o teto do dia.                                           |

O **dia da campanha** (`dayIndex`) avança no virar do dia, no timezone configurado — junto reseta o contador `sentToday`. Acompanhe `sentToday`/`capToday` e o `nextSendAt` em `GET /v1/campaigns/{id}`.

<Callout type="info">
  Defaults quando você omite a `cadence`: warmup base 20 / fator 1.2 / teto 200; janela 08:00–23:00
  America/Sao\_Paulo; ritmo 60s ±20%; auto-pausa em 5; validação **ligada**. A janela não pode virar
  a meia-noite (início precisa ser antes do fim).
</Callout>

## Disparando e controlando [#disparando-e-controlando]

| Ação                                  | Endpoint                                            |
| ------------------------------------- | --------------------------------------------------- |
| Disparar (DRAFT/SCHEDULED → RUNNING)  | `POST /v1/campaigns/{id}/start`                     |
| Pausar (RUNNING → PAUSED)             | `POST /v1/campaigns/{id}/pause`                     |
| Retomar (PAUSED → RUNNING)            | `POST /v1/campaigns/{id}/resume`                    |
| Encerrar / cancelar                   | `POST /v1/campaigns/{id}/complete` (`?cancel=true`) |
| Editar (só DRAFT/PAUSED)              | `PATCH /v1/campaigns/{id}`                          |
| Adicionar destinatários               | `POST /v1/campaigns/{id}/recipients`                |
| Remover destinatário (ainda pendente) | `DELETE /v1/campaigns/{id}/recipients/{sendId}`     |

`start` responde `202` e a engine assume — o envio é assíncrono.

## Acompanhando o resultado [#acompanhando-o-resultado]

**Agregado** — `GET /v1/campaigns/{id}` traz `totals` por status (`pending`, `sent`, `delivered`, `read`, `responded`, `failed`, `invalid`) e o `runtime` (dia, teto e enviados de hoje, último e próximo envio).

**Por destinatário** — `GET /v1/campaigns/{id}/recipients` lista cada contato com seu `status`, motivo de erro e marcos de tempo (paginado, filtrável por `?status=`).

**Tempo real** — assine o evento [`campaign.status`](/docs/webhooks/events#campaignstatus) (webhook ou WebSocket) para receber a mudança de status de cada destinatário (`sent`, `delivered`, `read`, `responded`, `failed`, `invalid`) assim que acontece.

```json
{
  "event": "campaign.status",
  "instanceId": "cm5x8a9k20001abc123def456",
  "data": { "campaignId": "camp_3Hk9Zq2pVxL", "recipient": "5511999999999", "status": "delivered" }
}
```

## Próximos passos [#próximos-passos]

* [Referência da API — Campanhas](/docs/api/campanhas/createCampaign)
* [Eventos de webhook](/docs/webhooks/events) — inclusive `campaign.status`
* [Inbox e histórico](/docs/crm/inbox) — as respostas dos contatos ficam registradas


# Departamentos (/docs/crm/departamentos)





Um **departamento** agrupa atendentes por área (Suporte, Financeiro, Comercial…). Cada atendente fica vinculado a um departamento; cada conversa "está" em um departamento e pode ser **transferida** entre eles. O caso típico: o contato cai no Suporte, o atendente percebe que é uma questão financeira e **transfere a conversa para o Financeiro** — ela sai da visão do Suporte e entra na fila do Financeiro.

<Callout type="info">
  Departamentos são **escopados ao Projeto** da sua API key e seguem a mesma autenticação por
  `Authorization: Bearer` do resto da API. O CRUD e a transferência também estão disponíveis para
  integradores via API key.
</Callout>

## O modelo [#o-modelo]

* **Atendente → 1 departamento.** Cada membro da equipe pode estar vinculado a um departamento (ou a nenhum). Definido na página **Equipe** do painel (ou via API do painel).
* **Conversa → 1 departamento (ou nenhum).** Uma conversa "está" em um departamento por vez. **Sem departamento = fila geral**, visível a todos os atendentes.
* **Como a conversa cai num departamento:** (1) pelo **número** — cada instância pode ter um departamento padrão (no cadastro da instância) que o ticket novo herda; (2) pelo **chatbot** — o nó "Transferir" do [construtor de fluxo](/docs/crm/fluxos) pode mandar pra um departamento; (3) por **transferência manual** (botão no topo do chat) ou pela API. Sem nenhum desses, a conversa fica na **fila geral** (sem departamento, visível a todos).
* **Roteamento automático respeita o departamento:** quando a [Equipe](/docs/crm/equipe) usa atribuição automática (menos ocupado / rodízio), o atendente escolhido sai **do departamento da conversa**; na fila geral, de qualquer atendente online.

### Visibilidade por departamento [#visibilidade-por-departamento]

A regra de quem enxerga o quê depende da permissão `ver_todas_conversas`:

| Quem                                               | Enxerga                                                                                                                         |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| Dono/Admin, ou atendente com `ver_todas_conversas` | **Todas** as conversas do Projeto.                                                                                              |
| Atendente **sem** `ver_todas_conversas`            | As conversas **atribuídas a ele** + as **sem dono** do **seu departamento** + as **sem dono da fila geral** (sem departamento). |

Ou seja: ao transferir uma conversa do Suporte para o Financeiro, ela **deixa de aparecer** para os atendentes do Suporte (que não têm "ver todas") e passa a aparecer para os do Financeiro. Conversas **sem departamento** ficam visíveis para todos — é a fila de triagem.

## Transferir uma conversa [#transferir-uma-conversa]

Transferir é o caminho principal e foi feito para ser rápido:

* **No painel:*&#x2A; no topo do chat, ao lado das etiquetas, há um botão com o departamento atual. Clique e escolha o destino (ou &#x2A;*"Fila geral"** para tirar o departamento).
* **Na API:** `POST /v1/inbox/conversations/{id}/department` com `{ "departmentId": "..." }`.

<Callout type="warn">
  A transferência **solta o atendente atual**: a conversa volta para a **fila do departamento de
  destino** (sem dono), para que alguém daquele departamento assuma. Envie `departmentId: null` para
  mandar para a fila geral.
</Callout>

## No painel [#no-painel]

| Ação                                  | Onde                                                                    |
| ------------------------------------- | ----------------------------------------------------------------------- |
| Criar/excluir departamento            | Inbox → engrenagem **Configurar atendimento** → aba **Deptos**          |
| Vincular atendente a um departamento  | **Equipe** → seletor de departamento no atendente                       |
| Departamento padrão do número         | **Instâncias** → seletor "Departamento padrão" na instância             |
| Transferir a conversa                 | **Inbox** → botão de departamento no topo do chat                       |
| Filtrar o inbox por departamento      | **Inbox** → chips de departamento acima da lista                        |
| Chatbot transfere pra um departamento | **Fluxos** → nó **Transferir** → campo "Transferir para o departamento" |

Gerenciar o catálogo de departamentos e vincular atendentes exige um usuário **dono ou administrador**. **Transferir** uma conversa é liberado a qualquer atendente com acesso à conversa.

## Na API [#na-api]

| Operação               | Método e rota                                                                                            |
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
| Listar departamentos   | [`GET /v1/inbox/departments`](/docs/api/departamentos/listDepartments)                                   |
| Criar departamento     | [`POST /v1/inbox/departments`](/docs/api/departamentos/createDepartment)                                 |
| Atualizar departamento | [`PATCH /v1/inbox/departments/{id}`](/docs/api/departamentos/updateDepartment)                           |
| Remover departamento   | [`DELETE /v1/inbox/departments/{id}`](/docs/api/departamentos/deleteDepartment)                          |
| Transferir a conversa  | [`POST /v1/inbox/conversations/{id}/department`](/docs/api/departamentos/transferConversationDepartment) |

A conversa no [Inbox](/docs/api/inbox/listInboxConversations) traz `departmentId` e `departmentName`; a listagem aceita o filtro `?departmentId=` para ver só um departamento. O **vínculo do atendente** a um departamento é feito pelo painel (não exposto na API de integração).

### Exemplos [#exemplos]

Criar um departamento:

```bash
curl -X POST https://api.pepinozap.com.br/v1/inbox/departments \
  -H "Authorization: Bearer pzk_sua_chave" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Financeiro", "description": "Cobrança, boletos e notas fiscais" }'
```

Transferir uma conversa para esse departamento:

```bash
curl -X POST https://api.pepinozap.com.br/v1/inbox/conversations/conv_123/department \
  -H "Authorization: Bearer pzk_sua_chave" \
  -H "Content-Type: application/json" \
  -d '{ "departmentId": "dep_financeiro" }'
```

Devolver a conversa para a fila geral (sem departamento):

```bash
curl -X POST https://api.pepinozap.com.br/v1/inbox/conversations/conv_123/department \
  -H "Authorization: Bearer pzk_sua_chave" \
  -H "Content-Type: application/json" \
  -d '{ "departmentId": null }'
```

## Pontos importantes [#pontos-importantes]

* **Soft-delete:** remover um departamento o desativa. As conversas que estavam nele **voltam para a fila geral** e os atendentes vinculados perdem o vínculo — nada de histórico quebra.
* **Nome único** por Projeto (case-insensitive); nome repetido retorna `409`.
* **Departamentos e [etiquetas](/docs/crm/etiquetas-encerramento) são ortogonais:** o departamento diz *qual fila* atende a conversa; a etiqueta é uma *marcação* livre. Uma conversa pode ter os dois.

Veja também &#x2A;*[Equipe e permissões](/docs/crm/equipe)** (atendentes, presença e a permissão `ver_todas_conversas` que rege a visibilidade acima).


# Equipe e permissões (/docs/crm/equipe)





O atendimento é feito por uma **equipe**: cada usuário do painel é um atendente com um **cargo** e, opcionalmente, **permissões granulares**. A presença (online/offline) e a política de **roteamento** decidem para quem as conversas novas vão.

<Callout type="info">
  Equipe, presença e permissões são geridos **no painel** (seção Equipe), sob a
  sessão do usuário logado — **não** pela API key de integração. Esta página
  documenta o **modelo**, para você entender como o roteamento e a visibilidade
  das conversas se comportam.
</Callout>

## Cargos [#cargos]

| Cargo                     | Pode                                                                                                   |
| ------------------------- | ------------------------------------------------------------------------------------------------------ |
| **Dono** (OWNER)          | Tudo, incluindo cobrança e gestão de administradores. Único por conta.                                 |
| **Administrador** (ADMIN) | Tudo no atendimento e na equipe, exceto mexer em outros administradores e na cobrança.                 |
| **Atendente** (MEMBER)    | O que as **permissões granulares** liberarem. Sem restrição definida = acesso de atendimento completo. |

Dono e administrador têm **acesso total** por definição — as permissões granulares só se aplicam a **atendentes**. Só o **dono** promove/rebaixa administradores.

## Permissões granulares [#permissões-granulares]

Para um atendente, o gestor marca exatamente o que ele pode. As chaves:

| Permissão              | Libera                                                                                                     |
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
| `ver_todas_conversas`  | Ver as conversas de **toda** a equipe. Sem ela, o atendente só vê as **atribuídas a ele** ou **sem dono**. |
| `atribuir`             | Atribuir/transferir conversas a atendentes.                                                                |
| `encerrar`             | Encerrar (resolver) e reabrir conversas.                                                                   |
| `ver_relatorios`       | Acessar a seção de [Relatórios](/docs/crm/relatorios).                                                     |
| `disparar_campanha`    | Criar e disparar campanhas (disparo em massa).                                                             |
| `gerenciar_instancias` | Conectar/remover números (instâncias).                                                                     |

<Callout type="warn">
  A permissão `ver_todas_conversas` é também um limite de **visibilidade de
  dados**: um atendente sem ela tem a listagem do Inbox filtrada no servidor
  para as próprias conversas — não é só esconder na interface. Gestão de
  equipe e cobrança **nunca** são delegáveis a atendente; ficam fora dessa lista.
</Callout>

## Presença (online/offline) [#presença-onlineoffline]

Cada atendente tem um estado de **presença** que o painel mantém por um *heartbeat* enquanto a aba está aberta. A presença é **efêmera**: expira sozinha em \~90 segundos sem sinal (fechou o navegador, caiu a rede → fica offline automaticamente). Ela alimenta o roteamento: conversas novas só são direcionadas a quem está **online**.

## Roteamento automático [#roteamento-automático]

Quando uma conversa nova chega **sem** um fluxo de chatbot ativo decidindo o destino (veja [Construtor de fluxo](/docs/crm/fluxos)), a plataforma a direciona segundo a **política do Projeto**:

| Política      | Comportamento                                                                    |
| ------------- | -------------------------------------------------------------------------------- |
| `manual`      | Ninguém é atribuído automaticamente; a conversa entra na fila para alguém pegar. |
| `least_busy`  | Vai para o atendente **online** com **menos** conversas em aberto.               |
| `round_robin` | **Rodízio** entre os atendentes online, distribuindo por igual.                  |

As duas políticas automáticas só consideram atendentes **online**. Se ninguém estiver online, a conversa fica na fila até alguém entrar ou pegá-la manualmente. Uma atribuição **manual** (pelo painel ou via [`assign`](/docs/api/inbox/assignConversation)) sempre prevalece sobre o automático para aquela conversa.

<Callout type="info">
  Quando há um **fluxo de chatbot ativo** na instância, ele tem **precedência**:
  o bot atende primeiro e só entrega ao humano (segundo a política acima) quando
  o fluxo chega a um nó de **transferência**. Detalhes em
  [Construtor de fluxo](/docs/crm/fluxos).
</Callout>


# Etiquetas e encerramento (/docs/crm/etiquetas-encerramento)





Duas ferramentas organizam o Inbox: **etiquetas** classificam conversas de forma transversal (VIP, Suporte, Vendas…) e as **justificativas de encerramento** registram *por que* cada atendimento foi resolvido. Ambas são catálogos do Projeto e podem ser geridas pela API.

## Etiquetas [#etiquetas]

Uma etiqueta tem **nome** e **cor**. A relação com conversas é **M:N**: uma conversa pode ter várias etiquetas e uma etiqueta classifica várias conversas.

```json
{ "id": "lbl_01HV...", "name": "VIP", "color": "amber" }
```

A **paleta de cores é fixa** (casa com o design do painel):

`gray` · `green` · `blue` · `purple` · `amber` · `red` · `cyan` · `pink`

Uma cor fora dessa lista cai para `gray`. O `name` é único no Projeto (máx. 40 caracteres) — repetir retorna `409`.

### Endpoints [#endpoints]

| Operação            | Método e caminho                                                                                        |
| ------------------- | ------------------------------------------------------------------------------------------------------- |
| Listar              | [`GET /v1/inbox/labels`](/docs/api/etiquetas/listLabels)                                                |
| Criar               | [`POST /v1/inbox/labels`](/docs/api/etiquetas/createLabel)                                              |
| Atualizar           | [`PATCH /v1/inbox/labels/{id}`](/docs/api/etiquetas/updateLabel)                                        |
| Remover             | [`DELETE /v1/inbox/labels/{id}`](/docs/api/etiquetas/deleteLabel)                                       |
| Aplicar à conversa  | [`PUT /v1/inbox/conversations/{id}/labels/{labelId}`](/docs/api/etiquetas/assignConversationLabel)      |
| Remover da conversa | [`DELETE /v1/inbox/conversations/{id}/labels/{labelId}`](/docs/api/etiquetas/unassignConversationLabel) |

Aplicar é **idempotente** — aplicar a mesma etiqueta de novo não duplica. As etiquetas de uma conversa vêm no campo `labels` ao [detalhar a conversa](/docs/api/inbox/getInboxConversation).

### Filtrar o Inbox por etiqueta [#filtrar-o-inbox-por-etiqueta]

A listagem de conversas aceita `labelId`: [`GET /v1/inbox/conversations?labelId=lbl_01HV...`](/docs/api/inbox/listInboxConversations) traz só as conversas com aquela etiqueta — útil para montar filas segmentadas (ex.: "todas as VIP em aberto") a partir do seu sistema.

<Callout type="info">
  **Soft-delete.** Remover uma etiqueta a **desativa**: ela some da paleta e das
  conversas ativas, mas o histórico que já a referenciava não quebra. Não há
  "lixeira" — para voltar a usar, crie de novo.
</Callout>

## Justificativas de encerramento [#justificativas-de-encerramento]

São os **motivos** que o atendente escolhe ao resolver uma conversa (ex.: "Resolvido", "Sem resposta do cliente", "Duplicado"). Catálogo do Projeto:

```json
{ "id": "cr_01HV...", "name": "Resolvido", "position": 0 }
```

`name` é único (máx. 60 caracteres); `position` é a ordem de exibição. Mesmo soft-delete das etiquetas: remover desativa, sem quebrar conversas já encerradas com aquele motivo.

| Operação | Método e caminho                                                                    |
| -------- | ----------------------------------------------------------------------------------- |
| Listar   | [`GET /v1/inbox/close-reasons`](/docs/api/justificativas/listCloseReasons)          |
| Criar    | [`POST /v1/inbox/close-reasons`](/docs/api/justificativas/createCloseReason)        |
| Remover  | [`DELETE /v1/inbox/close-reasons/{id}`](/docs/api/justificativas/deleteCloseReason) |

## Status da conversa e encerramento [#status-da-conversa-e-encerramento]

Toda conversa tem um **status**: `open`, `pending` ou `resolved`. Você o muda com [`POST /v1/inbox/conversations/{id}/status`](/docs/api/inbox/setConversationStatus).

Ao **resolver** (`status: resolved`), anexe a justificativa:

```json
{
  "status": "resolved",
  "closeReasonId": "cr_01HV...",
  "closeNotes": "Cliente resolveu pelo app."
}
```

* `closeReasonId` — opcional, mas precisa ser um motivo **existente** do Projeto; inválido retorna `400`.
* `closeNotes` — observação livre, opcional.
* **Reabrir** (voltar para `open`/`pending`) **limpa** a justificativa anterior.

## Atribuir a um atendente [#atribuir-a-um-atendente]

Direcione a conversa a um atendente com [`POST /v1/inbox/conversations/{id}/assign`](/docs/api/inbox/assignConversation):

```json
{ "userId": "usr_01HV..." }   // null desatribui
```

O atendente precisa pertencer ao mesmo Projeto. A atribuição manual **fixa o dono** daquela conversa e convive com o roteamento automático configurado na [Equipe](/docs/crm/equipe).

<Callout type="info">
  Pela **API key** (integração), as ações de status e atribuição não checam
  permissão de atendente — a key tem escopo de Projeto. As permissões granulares
  valem para **usuários do painel**; veja [Equipe e permissões](/docs/crm/equipe).
</Callout>


# Construtor de fluxo (chatbot) (/docs/crm/fluxos)





O **construtor de fluxo** é um chatbot visual: você desenha, num canvas, o caminho que uma conversa percorre — uma saudação, um menu de opções, uma pergunta, uma condição, uma espera — e a plataforma executa esse fluxo **automaticamente** quando uma mensagem chega, antes de qualquer atendente humano. É o mesmo recurso do editor de Fluxos no painel; esta página explica o **modelo** e como operá-lo pela API.

* **No painel:** Fluxos → (novo/editar) — editor visual com arrastar-e-soltar.
* **Na API:** [Fluxos](/docs/api/fluxos/listFlows) — o editor usa exatamente estes endpoints.

## Anatomia de um fluxo [#anatomia-de-um-fluxo]

Um **fluxo** é um cabeçalho (nome, instância, gatilho, ativo) + um conjunto de **nós** ligados por arestas. Regras:

* Um fluxo é **vinculado a uma instância** (número) — ou a nenhuma, valendo para qualquer número do Projeto.
* **Só 1 fluxo pode ficar ATIVO por instância.** Ativar um desativa os outros daquela instância automaticamente.
* O nó de &#x2A;*menor `ordem`** é o ponto de partida.
* Cada nó tem uma posição no canvas (`posicaoX`/`posicaoY`) — puramente visual — e um `conteudo` (objeto JSON) cujo formato depende do **tipo**.

### O gatilho [#o-gatilho]

Quando o fluxo dispara para uma conversa nova:

| `triggerType` | Dispara quando                                                                       |
| ------------- | ------------------------------------------------------------------------------------ |
| `any`         | **Qualquer** mensagem recebida inicia o fluxo.                                       |
| `keyword`     | Só quando o texto recebido **contém** o `triggerValue` (sem diferenciar maiúsculas). |

## Tipos de nó [#tipos-de-nó]

Cada nó tem um `tipo` e um `conteudo`. Os formatos:

### `message` — envia um texto [#message--envia-um-texto]

```json
{ "texto": "Olá {{nome}}, tudo bem? Bem-vindo à Loja X." }
```

Envia a mensagem e segue para o próximo nó (aresta padrão). Suporta [variáveis](#variáveis).

### `menu` — oferece opções numeradas [#menu--oferece-opções-numeradas]

```json
{
  "texto": "Como posso ajudar?",
  "options": [
    { "id": "opt_1", "numero": 1, "texto": "Falar com vendas", "targetNodeId": "nd_vendas" },
    { "id": "opt_2", "numero": 2, "texto": "Suporte", "targetNodeId": "nd_suporte" }
  ]
}
```

Envia o `texto` seguido das opções **numeradas automaticamente** (`1. Falar com vendas` …) e **espera a resposta**. O contato pode responder pelo **número** (`1`) ou pelo **texto** da opção. A resposta leva ao `targetNodeId` daquela opção. Resposta que não casa com nenhuma opção → o menu é **reenviado**.

### `input` — pergunta e guarda a resposta [#input--pergunta-e-guarda-a-resposta]

```json
{ "texto": "Qual o seu nome?", "variavel": "nome_cliente" }
```

Envia a pergunta, **espera a resposta** e guarda o que o contato digitar na variável indicada (aqui, `{{nome_cliente}}`), disponível nos próximos nós. Depois segue pela aresta padrão.

### `condition` — bifurca o caminho [#condition--bifurca-o-caminho]

```json
{ "variavel": "nome_cliente", "operador": "contem", "valor": "João", "onTrue": "nd_a", "onFalse": "nd_b" }
```

Avalia `variavel <operador> valor` e vai para `onTrue` ou `onFalse`. Operadores: `==` (igual), `!=` (diferente), `contem` (substring, sem diferenciar maiúsculas). Não envia mensagem — só decide o ramo.

### `delay` — espera antes de seguir [#delay--espera-antes-de-seguir]

```json
{ "segundos": 30 }
```

Pausa o fluxo pelo tempo indicado e **depois** segue. A espera é **não-bloqueante**: a sessão fica agendada e um agendador a retoma quando o tempo vence — não trava o atendimento. `0` segue na hora; o teto é **24 horas** (86400s).

### `action` — gancho de automação [#action--gancho-de-automação]

Nó reservado para automações (ex.: criar um negócio no funil). Hoje apenas **segue** para o próximo nó; é o ponto de extensão para evoluções.

### `transfer` — entrega ao humano [#transfer--entrega-ao-humano]

Encerra a parte automática e **transfere para um atendente**, seguindo a [política de roteamento](/docs/crm/equipe#roteamento-automático) do Projeto (menos ocupado, rodízio ou manual). É como o bot "passa o bastão".

### `close` — encerra a conversa [#close--encerra-a-conversa]

Finaliza o fluxo e marca a conversa como **resolvida**. Use ao fim de um autoatendimento que não precisa de humano.

## Variáveis [#variáveis]

No início de cada sessão, o fluxo já recebe dados do contato, usáveis com `{{chave}}` em qualquer `texto`:

| Variável               | Conteúdo                                           |
| ---------------------- | -------------------------------------------------- |
| `{{nome}}`             | Nome do contato (push name do WhatsApp).           |
| `{{numero}}`           | Número do contato.                                 |
| `{{numero_formatado}}` | Número do contato (formatado).                     |
| `{{mensagem_inicial}}` | O texto da primeira mensagem que disparou o fluxo. |

Nós do tipo `input` **adicionam** novas variáveis (o que o contato responder). Uma variável inexistente é substituída por vazio.

## Comportamento em tempo de execução [#comportamento-em-tempo-de-execução]

<Callout type="info">
  **O bot tem precedência sobre o roteamento humano.** Se há um fluxo ativo na
  instância, ele decide o atendimento; o roteamento automático para atendentes
  só entra quando o fluxo chega a um nó `transfer`.
</Callout>

* **Convivência com humano.** Se a conversa **já tem um atendente atribuído**, o bot **pausa** — não atropela o atendimento humano em andamento.
* **Uma sessão por conversa.** O estado (nó atual, variáveis, espera agendada) é mantido por conversa enquanto o fluxo roda.
* **Envio durável.** As mensagens do bot saem pela fila de envio confiável (com retries), igual aos demais envios da API.
* **Proteção contra laço.** Um fluxo mal montado (ciclo entre nós que não esperam resposta) é interrompido após um limite de passos, em vez de rodar para sempre.
* **Apagar o fluxo** encerra as sessões que estiverem rodando nele.

## Montando um fluxo pela API [#montando-um-fluxo-pela-api]

O editor visual faz isto nos bastidores; você pode reproduzir programaticamente:

1. **Crie o fluxo** — [`POST /v1/flows`](/docs/api/fluxos/createFlow) com `name`, `instanceId` e `triggerType`. Nasce **inativo**.
2. **Adicione os nós** — [`POST /v1/flows/{id}/nodes`](/docs/api/fluxos/createFlowNode), um por nó, com `tipo`, `conteudo`, posição e `ordem` (o menor é o início).
3. **Ligue os nós** — a aresta padrão (linear) com [`PATCH .../nodes/{nodeId}/connection`](/docs/api/fluxos/setFlowNodeConnection) (`proximoNodeId`); as saídas de um **menu** com [`PATCH .../nodes/{nodeId}/option-connection`](/docs/api/fluxos/setFlowNodeOptionConnection) (liga cada `optionId` a um nó).
4. **Ative** — [`PATCH /v1/flows/{id}`](/docs/api/fluxos/updateFlow) com `active: true`. Isso desativa os outros fluxos da mesma instância (conflito retorna `409`).

<Callout type="warn">
  Ao **ativar**, garanta que o fluxo tem um nó inicial (a menor `ordem`) e que as
  ligações levam a um nó terminal (`transfer` ou `close`) — senão a conversa
  pode ficar no bot sem saída para um humano. Teste no ambiente **sandbox** antes
  de ativar em produção.
</Callout>


# Funil configurável (/docs/crm/funil)





O funil do PEPINO ZAP não é um quadro de cards que você arrasta à mão: é **configurável por regras**. Você cria **etapas** e, em cada uma, define **uma regra sobre a conversa**. As conversas se distribuem sozinhas pelas etapas conforme atendem (ou não) a cada regra — então o funil reflete o estado real do atendimento, sem ninguém mover nada.

* **No painel:** Funil → (criar/editar) — canvas com arrastar-e-soltar, no mesmo estilo do [construtor de fluxo](/docs/crm/fluxos).
* **Na API:** [Funil](/docs/api/funil/listFunnels) — o editor visual usa exatamente estes endpoints.

## Como uma conversa cai numa etapa [#como-uma-conversa-cai-numa-etapa]

Cada etapa tem uma `position` (ordem). Uma conversa é colocada na **etapa de maior `position` cuja regra ela satisfaz**. É isso que dá o **formato de funil**: as etapas iniciais pegam muitas conversas, as finais (mais avançadas) pegam menos, e a queda entre elas é a sua taxa de conversão.

* Uma conversa fica em **uma** etapa (a mais avançada que casa).
* Conversas que não casam com nenhuma etapa ficam **fora do funil** (`unplaced`).
* A avaliação é feita **na leitura** ([`GET /v1/funnels/{id}/data`](/docs/api/funil/getFunnelData)), sobre as conversas mais recentes do escopo (instância opcional do funil).

## A regra de uma etapa [#a-regra-de-uma-etapa]

A regra combina condições com &#x2A;*E (AND)** — a conversa precisa satisfazer **todas** as condições presentes. Condição vazia/"any" = sem restrição.

| Critério                    | Valores                                 | A conversa entra quando…                                                |
| --------------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
| **Origem**                  | `any` · `inbound` · `outbound`          | foi **receptiva** (o cliente iniciou) ou **ativa** (a empresa iniciou). |
| **Status**                  | `any` · `open` · `pending` · `resolved` | está aberta, pendente ou resolvida.                                     |
| **Etiqueta**                | `labelId`                               | tem a etiqueta indicada (ex.: "Proposta enviada").                      |
| **Posição no fluxo de bot** | ver abaixo                              | está/passou por um ponto do seu [fluxo de chatbot](/docs/crm/fluxos).   |

### Posição no fluxo de bot (`flowMode`) [#posição-no-fluxo-de-bot-flowmode]

| `flowMode`    | Significado                                                        |
| ------------- | ------------------------------------------------------------------ |
| `any`         | Não considera o fluxo.                                             |
| `in_flow`     | A conversa está num fluxo ativo (opcional: `flowId` específico).   |
| `at_node`     | Está **no nó** `nodeId` do fluxo `flowId` (um ponto exato do bot). |
| `completed`   | **Concluiu** o fluxo (`flowId` específico ou qualquer um).         |
| `not_in_flow` | Não está em nenhum fluxo.                                          |

<Callout type="info">
  Combine os critérios para etapas precisas. Exemplos:
  <br />• "Novos leads" → `origin: inbound`, `flowMode: in_flow`.
  <br />• "Qualificados" → `flowMode: at_node` no nó "Quer orçamento?" do seu fluxo.
  <br />• "Proposta enviada" → `labelId` da etiqueta Proposta, `status: open`.
  <br />• "Ganhos" → `status: resolved`, `labelId` da etiqueta Fechado.
</Callout>

## Visualização (canvas) [#visualização-canvas]

O funil é desenhado num **canvas** (igual ao editor de fluxo): cada etapa é um nó com o **nome**, a **contagem** de conversas e um resumo da regra. Você **arrasta** as etapas para organizar, **liga** uma na outra (aresta visual) e, ao **clicar numa etapa**, vê as conversas que chegaram ali. A ligação entre etapas é apenas cosmética — quem decide o conteúdo da etapa é a **regra**, não a aresta.

## Montando um funil pela API [#montando-um-funil-pela-api]

1. **Crie o funil** — [`POST /v1/funnels`](/docs/api/funil/createFunnel) com `name` e, opcionalmente, `instanceId` (limita a uma instância).
2. **Adicione as etapas** — [`POST /v1/funnels/{id}/stages`](/docs/api/funil/createFunnelStage), uma por etapa, com `name`, `color`, a `rule` e `position` (a ordem; maior = mais avançada).
3. **(Opcional) Ligue as etapas** no canvas com [`PATCH .../stages/{stageId}/connection`](/docs/api/funil/setFunnelStageConnection).
4. **Leia os dados** — [`GET /v1/funnels/{id}/data`](/docs/api/funil/getFunnelData): contagem e até 50 conversas por etapa, mais `unplaced` e `totalEvaluated`.

```bash
# Cria o funil
curl -X POST https://api.pepinozap.com.br/v1/funnels \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Funil de vendas" }'

# Adiciona uma etapa "Proposta" (conversas abertas com a etiqueta X)
curl -X POST https://api.pepinozap.com.br/v1/funnels/fnl_.../stages \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Proposta", "color": "amber", "position": 2,
        "rule": { "status": "open", "labelId": "lbl_..." } }'

# Lê o funil (contagem + conversas por etapa)
curl https://api.pepinozap.com.br/v1/funnels/fnl_.../data \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx"
```

<Callout type="warn">
  A avaliação é **on-read** sobre as **5.000 conversas mais recentes** do escopo.
  Como cada conversa fica na etapa **mais avançada** que casa, etapas com regras
  mais "amplas" (ex.: só `status: open`) devem ter `position` **menor** que
  etapas mais específicas — senão elas "capturam" conversas que deveriam aparecer
  mais à frente.
</Callout>


# Inbox — conversas e mensagens (/docs/crm/inbox)





O inbox é o **sistema de registro** das conversas: todo o histórico de mensagens, a mídia hospedada e os metadados de cada contato ficam acessíveis pela API `/v1/inbox/*`, independente de qual instância (número) recebeu cada mensagem.

<Callout type="warn">
  As conversas **não** ficam sob a instância. Não procure por `/instances/{id}/conversations&#x60; — o
  caminho é &#x2A;*`/v1/inbox/conversations`**. A instância é apenas o número que enviou/recebeu; o
  histórico é centralizado no inbox.
</Callout>

<Callout type="warn">
  **As conversas seguem o modo (teste/produção) da instância** — é o mesmo [isolamento de dados por
  modo](/docs/get-started/authentication#isolamento-de-dados-por-modo) das instâncias e webhooks. A
  conversa de uma instância de **teste** só aparece pela **API de teste**
  (`sandbox.pepinozap.com.br` + key `pzk_test_`); o **Inbox do painel é sempre Produção**, então
  conversa de instância de teste **não aparece lá** — não é bug, é o modo. Para vê-la no painel, use
  uma instância de **produção** (key `pzk_live_` / host `api.*`).
</Callout>

## Listar conversas [#listar-conversas]

[`GET /v1/inbox/conversations`](/docs/api/inbox/listInboxConversations) devolve as conversas com o **resumo** (último trecho, não-lidas, status, etiquetas, atendente/departamento atribuído) — **sem** o corpo das mensagens. Aceita filtros por `status` (`open`/`pending`/`resolved`), `label` e `department`, e ecoa os **contadores** por status para os badges das abas.

Use essa lista para a coluna da esquerda do seu inbox e re-busque-a periodicamente (ou por evento de WebSocket) para manter os badges e o último trecho atualizados.

## Carregar as mensagens de uma conversa [#carregar-as-mensagens-de-uma-conversa]

[`GET /v1/inbox/conversations/{id}/messages`](/docs/api/inbox/listInboxMessages) devolve a **leva mais recente** de mensagens da conversa (texto, mídia, direção, timestamps, status de entrega). Para o histórico mais antigo:

| Como                        | Endpoint                                                                                  |
| --------------------------- | ----------------------------------------------------------------------------------------- |
| Paginar para trás           | `GET .../messages?before=<messageId>` (mais antigas que aquela mensagem)                  |
| Puxar histórico do WhatsApp | [`POST /v1/inbox/conversations/{id}/history`](/docs/api/inbox/requestConversationHistory) |

<Callout type="info">
  Cada mensagem tem uma **direção** (`inbound`/`outbound`). O `outbound` inclui tanto o que sua
  integração/painel envia quanto o que a equipe responde **direto no celular** — o número é
  multi-device, então o que sai de qualquer aparelho é espelhado aqui (e conta no tempo de resposta
  dos relatórios). O webhook [`message.received`](/docs/webhooks/events) dispara &#x2A;*só para
  `inbound`**; mensagem que sai não gera `message.received`.
</Callout>

## Padrões para um inbox responsivo [#padrões-para-um-inbox-responsivo]

A diferença entre um inbox que "abre na hora" e um que pisca a cada clique está no **cliente**, não no endpoint. Quatro padrões que valem a pena seguir:

### 1. Cache por conversa (stale-while-revalidate) [#1-cache-por-conversa-stale-while-revalidate]

Não re-busque do zero (com skeleton) toda vez que o usuário abre uma conversa que **já viu nesta sessão**. Guarde as mensagens por `conversationId` no cliente:

* **Ao abrir:** se há cache para aquela conversa, mostre-o **na hora** (sem skeleton) e dispare o fetch em background para revalidar — atualize a tela se algo mudou.
* **Skeleton só na primeira** abertura de cada conversa (cold load).

Isso mantém a navegação entre conversas instantânea e evita o "pisca-skeleton" a cada clique. Limpar tudo e mostrar skeleton em **toda** abertura é a causa nº 1 de inbox que "ficou lento".

### 2. Preserve as URLs de mídia [#2-preserve-as-urls-de-mídia]

A `mediaUrl` de cada mensagem é uma URL **presigned, reassinada a cada resposta** (e que expira). Se você trocar o `src` de uma `<img>`/`<video>` a cada revalidação, o navegador **recarrega a mídia** — o vídeo trava reiniciando do zero.

Guarde a URL da **primeira** vez que carregou cada mensagem (por `messageId`) e reuse-a nas revalidações; só mensagens **novas** usam a URL fresca da resposta.

<Callout type="info">
  Porque a URL presigned **expira**, ela não serve como link durável. Para um link compartilhável ou
  para reter a mídia por conta própria, **rehospede** o arquivo do seu lado a partir do primeiro
  download. Veja [Persistência](/docs/get-started/persistencia).
</Callout>

### 3. Mantenha ao vivo — polling ou WebSocket [#3-mantenha-ao-vivo--polling-ou-websocket]

* **Polling:** re-busque as mensagens da conversa **aberta** a cada \~5s e a lista de conversas a cada \~10s. Simples e suficiente para a maioria dos casos.
* **WebSocket (push):** menor latência — em vez de esperar o próximo tick (num polling de 5s são \~2.5s de atraso médio), o evento `message.received` chega em **menos de 100ms** e você recarrega na hora. Veja [Tempo real por WebSocket](/docs/webhooks/websocket).

**Padrão recomendado (o que o próprio painel da PEPINO ZAP usa):*&#x2A; WebSocket para o push instantâneo &#x2A;*+** um polling lento como **rede de segurança**. O WS entrega a latência baixa; o polling (mais espaçado) garante que nada se perca se a conexão cair entre um evento e a reconexão. Na prática: no `message.received`, recarregue a conversa aberta e a lista; mantenha um `setInterval` de fallback; e, num front **multi-usuário**, conecte o WS com o [token efêmero](/docs/webhooks/websocket#token-efemero-para-o-navegador), não com a API key.

Os dois combinam bem com o cache do item 1: o evento (ou o tick do polling) revalida a conversa aberta; o resto continua servido do cache.

### 4. Role para o fim ao abrir [#4-role-para-o-fim-ao-abrir]

Ao abrir uma conversa, role para o fim de forma **instantânea** (não suave) e **re-cole no fim** conforme cada mídia carrega:

* Imagem e vídeo **mudam de altura depois de carregar** e empurram o conteúdo para baixo — uma rolagem disparada cedo "para no meio" quando a mídia chega.
* **Re-cole no fim com um `ResizeObserver`** no container das mensagens, **não** com o `onload` de cada mídia. Ao reabrir uma conversa, o cache reusa a **mesma URL** de mídia → o navegador serve do **cache dele** e o evento `load` pode disparar **antes** do seu handler ser anexado, perdendo a re-rolagem (a conversa trava "no meio"). O `ResizeObserver` pega **qualquer** mudança de altura, independente do `onload`.
* Só auto-role se o usuário estiver **colado no fim** (distância até a última mensagem abaixo de um limiar, ex. 120px). Se ele rolou para cima lendo o histórico, **não o arranque** do lugar.

<Callout type="info">
  Combinado com o cache do item 1, reabrir uma conversa já vista é instantâneo **e** já no fim — sem
  skeleton e sem a rolagem visível "descendo até a última mensagem".
</Callout>


# Visão geral do CRM (/docs/crm)





Além de conectar números e enviar mensagens, o PEPINO ZAP é um **CRM de atendimento**: as conversas recebidas viram um Inbox compartilhado pela equipe, com status, atribuição a atendentes, etiquetas, justificativas de encerramento, relatórios de gestão e um construtor de chatbot. Esta seção documenta **o que cada recurso faz**, o **modelo de dados** por trás dele e **como consumi-lo pela API** — para quem quer automatizar a gestão a partir do próprio sistema.

<Callout type="info">
  Tudo aqui é **escopado ao Projeto** da sua API key e respeita o modo (Teste/Produção) do host,
  igual ao resto da API. Os endpoints citados usam a mesma autenticação por `Authorization: Bearer`
  descrita na [Visão geral da API](/docs/api).
</Callout>

## O que existe [#o-que-existe]

| Recurso                            | O que é                                                                                                             | No painel           | Na API                                                                                                                   |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| **Relatórios**                     | Métricas de gestão do atendimento (receptivo×ativo, resolvidas, tempo de 1ª resposta, produtividade por atendente). | Gestão → Relatórios | [getReportsSummary](/docs/api/relatorios/getReportsSummary) — guia em [Relatórios](/docs/crm/relatorios)                 |
| **Etiquetas**                      | Tags coloridas para classificar e filtrar conversas.                                                                | Inbox (na conversa) | [Etiquetas](/docs/api/etiquetas/listLabels) — guia em [Etiquetas e encerramento](/docs/crm/etiquetas-encerramento)       |
| **Justificativas de encerramento** | Catálogo de motivos registrados ao resolver uma conversa.                                                           | Inbox → Resolver    | [Justificativas](/docs/api/justificativas/listCloseReasons)                                                              |
| **Status e atribuição**            | Abrir/pendência/resolver e direcionar a conversa a um atendente.                                                    | Inbox               | [setConversationStatus](/docs/api/inbox/setConversationStatus), [assignConversation](/docs/api/inbox/assignConversation) |
| **Equipe e permissões**            | Atendentes, presença (online/offline), permissões granulares e política de roteamento.                              | Equipe              | Gerido pelo painel — modelo em [Equipe e permissões](/docs/crm/equipe)                                                   |
| **Departamentos**                  | Organiza a equipe em departamentos e transfere conversas entre eles (Suporte → Financeiro).                         | Inbox + Equipe      | [Departamentos](/docs/api/departamentos/listDepartments) — guia em [Departamentos](/docs/crm/departamentos)              |
| **Construtor de fluxo**            | Chatbot visual que responde automaticamente por número.                                                             | Fluxos              | [Fluxos](/docs/api/fluxos/listFlows) — guia em [Construtor de fluxo](/docs/crm/fluxos)                                   |
| **Funil configurável**             | Funil cujas etapas são regras sobre a conversa (origem, status, etiqueta, posição no fluxo).                        | Funil               | [Funil](/docs/api/funil/listFunnels) — guia em [Funil configurável](/docs/crm/funil)                                     |

## Como o Inbox se relaciona com a API de mensagens [#como-o-inbox-se-relaciona-com-a-api-de-mensagens]

O Inbox é a **leitura** persistida das conversas (veja [Inbox — conversas e mensagens](/docs/crm/inbox)); o **envio** de respostas continua sendo feito pelos endpoints de [Mensagens](/docs/api/mensagens/sendTextMessage) (`POST /v1/instances/{id}/messages/...`). Os recursos de gestão desta seção operam **sobre** essas conversas: marcam status, aplicam etiquetas, atribuem atendentes e — no caso do construtor de fluxo — respondem sozinhos antes de um humano assumir.

## Por onde começar [#por-onde-começar]

* **Quero consumir/construir o inbox** → [Inbox — conversas e mensagens](/docs/crm/inbox) (listar conversas, carregar mensagens e os padrões de uma UI responsiva).
* **Quero medir o atendimento** → [Relatórios](/docs/crm/relatorios) (o modelo de dados de cada métrica).
* **Quero classificar e encerrar conversas** → [Etiquetas e encerramento](/docs/crm/etiquetas-encerramento).
* **Quero entender atendentes e permissões** → [Equipe e permissões](/docs/crm/equipe).
* **Quero organizar a equipe em departamentos** → [Departamentos](/docs/crm/departamentos) (vincular atendentes + transferir conversas).
* **Quero automatizar respostas (chatbot)** → [Construtor de fluxo](/docs/crm/fluxos).
* **Quero ver o funil de vendas** → [Funil configurável](/docs/crm/funil) (etapas por regra sobre a conversa).


# Relatórios e métricas (/docs/crm/relatorios)





O relatório de gestão entrega, **já agregadas**, as métricas que você veria num dashboard de atendimento: quantas conversas entraram, quantas a empresa iniciou, quantas foram resolvidas, quanto tempo a equipe leva para dar a primeira resposta e o quanto cada atendente produziu. Tudo num único endpoint — você **não reimplementa** a heurística no seu lado.

* **Endpoint:** [`GET /v1/inbox/reports/summary`](/docs/api/relatorios/getReportsSummary)
* **No painel:** Gestão → Relatórios.

## A janela de tempo [#a-janela-de-tempo]

A janela é definida por &#x2A;*datas no formato `YYYY-MM-DD`**, interpretadas no &#x2A;*fuso de Brasília (UTC−3)**:

| Parâmetro | Significado                                                    |
| --------- | -------------------------------------------------------------- |
| `from`    | Início da janela (inclusivo). Omitido = 29 dias antes de `to`. |
| `to`      | Fim da janela — inclui **o dia inteiro**. Omitido = hoje.      |

O padrão (sem `from`/`to`) são os **últimos 30 dias**. A janela máxima é de **366 dias**; pedidos maiores são recortados. A resposta **ecoa** a janela efetiva em `range`, então você sempre sabe o intervalo exato que foi medido.

<Callout type="info">
  As datas são do **fuso de Brasília**, mas os timestamps das conversas são
  gravados em UTC — a conversão é feita no servidor. Um relatório de
  `from=2026-06-01&to=2026-06-01` cobre o dia 1º de junho inteiro no horário de
  Brasília (de 00:00 a 23:59:59 BRT).
</Callout>

## Filtros [#filtros]

| Parâmetro        | Efeito                                                           |
| ---------------- | ---------------------------------------------------------------- |
| `instanceId`     | Restringe a uma instância (número). Vazio = todas.               |
| `assignedUserId` | Restringe às conversas atribuídas a um atendente. Vazio = todos. |

Os filtros se aplicam aos cards e às séries; a produtividade `porAtendente` ignora `assignedUserId` (ela já é quebrada por atendente).

## O modelo de dados da resposta [#o-modelo-de-dados-da-resposta]

```json
{
  "range": { "from": "2026-05-08", "to": "2026-06-06" },
  "total": 120,
  "receptivos": 80,
  "ativos": 40,
  "pendentes": 5,
  "abertas": 12,
  "resolvidas": 103,
  "novosContatos": 34,
  "atendentes": { "total": 4 },
  "primeiraResposta": {
    "mediaSegundos": 210,
    "medianaSegundos": 150,
    "amostras": 95
  },
  "porAtendente": [
    { "userId": "usr_01HV...", "nome": "Ana", "email": "ana@empresa.com", "total": 40, "resolvidas": 35 }
  ],
  "porDia": [
    { "dia": "2026-06-01", "abertas": 7 }
  ]
}
```

### Os cards [#os-cards]

| Campo              | O que conta                                                               |
| ------------------ | ------------------------------------------------------------------------- |
| `total`            | Total de conversas **abertas na janela** (todas as iniciadas no período). |
| `receptivos`       | Conversas **receptivas**: o **contato** mandou a primeira mensagem.       |
| `ativos`           | Conversas **ativas**: a **empresa** iniciou o contato.                    |
| `pendentes`        | Conversas em status `pending` na janela.                                  |
| `abertas`          | Conversas em status `open` na janela.                                     |
| `resolvidas`       | Conversas marcadas como `resolved` na janela.                             |
| `novosContatos`    | Contatos criados (primeiro contato de sempre) na janela.                  |
| `atendentes.total` | Número de atendentes do Projeto.                                          |

<Callout type="info">
  **Receptivo×ativo** sai da **direção de abertura** da conversa: quem enviou a
  primeira mensagem. Se foi o contato, a conversa é *receptiva*; se foi a sua
  equipe (envio ativo, campanha, primeira mensagem pelo painel/API), é *ativa*.
  `receptivos + ativos` cobre o `total`.
</Callout>

### Tempo de primeira resposta [#tempo-de-primeira-resposta]

O bloco `primeiraResposta` mede, **em segundos**, o intervalo entre a primeira mensagem **recebida** numa conversa e a primeira **resposta** da equipe:

| Campo             | Significado                                                             |
| ----------------- | ----------------------------------------------------------------------- |
| `mediaSegundos`   | Média aritmética do tempo de 1ª resposta.                               |
| `medianaSegundos` | Mediana (menos sensível a outliers — um caso muito lento não distorce). |
| `amostras`        | Quantas conversas tinham uma 1ª resposta para entrar no cálculo.        |

Use a **mediana** como indicador principal de SLA: ela representa o atendimento "típico". A média ajuda a detectar caudas longas (poucas conversas muito demoradas puxam a média para cima sem mexer na mediana). `amostras` diz o tamanho da base — uma mediana sobre 3 conversas não é representativa.

### Produtividade por atendente [#produtividade-por-atendente]

`porAtendente` é uma lista, uma entrada por atendente que teve conversas na janela:

| Campo            | Significado                                       |
| ---------------- | ------------------------------------------------- |
| `userId`         | Id do atendente (`null` para conversas sem dono). |
| `nome` / `email` | Identificação do atendente.                       |
| `total`          | Conversas atribuídas a ele na janela.             |
| `resolvidas`     | Quantas dessas ele resolveu.                      |

Conversas **sem atendente** aparecem agregadas numa entrada com `userId: null` — é a sua fila não distribuída.

### Série diária [#série-diária]

`porDia` é a série para o gráfico de linha/coluna: uma entrada por dia da janela, com `dia` (`YYYY-MM-DD`) e `abertas` (conversas iniciadas naquele dia). Dias sem movimento podem não aparecer — trate ausência como zero ao plotar.

## Exemplo [#exemplo]

```bash
curl "https://api.pepinozap.com.br/v1/inbox/reports/summary?from=2026-06-01&to=2026-06-06" \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx"

# Filtrando por um número e um atendente:
curl "https://api.pepinozap.com.br/v1/inbox/reports/summary?instanceId=inst_01HV...&assignedUserId=usr_01HV..." \
  -H "Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx"
```

<Callout type="warn">
  O relatório é uma **fotografia do estado atual** das conversas na janela:
  `resolvidas`, `pendentes` e `abertas` refletem o status **agora**, não no fim
  do período. Para histórico imutável, persista os números do seu lado a cada
  fechamento de período.
</Callout>


# Autenticação (/docs/get-started/authentication)





Toda requisição à API do PEPINO ZAP é autenticada por uma **API key**, escopada a um **Projeto**. A key identifica quem está chamando e a qual Projeto pertence: todas as instâncias, conversas e mensagens criadas ou listadas com uma key pertencem ao Projeto dela. Não há login/senha nas chamadas de API — só a key, enviada em cada requisição.

A geração de keys é **self-service**: você cria e revoga quantas quiser pelo painel, sem depender da autorização de um operador. Esta página cobre o fluxo completo, ponta a ponta:

1. Criar a conta e confirmar o e-mail.
2. Escolher o **modo** (teste ou produção) e gerar a key.
3. Enviar a key nas requisições (header `Authorization`).
4. O caso especial do **SSE de QR Code** (fallback `?token=`).
5. Todos os **erros** que a autenticação pode retornar (`401`, `402`, `403`, `429`) e como resolver cada um.

<Callout type="info">
  Resumo em uma frase: o **host** decide o modo (`sandbox.*` = teste, `api.*` =
  produção) e a **key tem que casar** com ele (`pzk_test_` no sandbox,
  `pzk_live_` na produção). Key no host errado retorna `401`.
</Callout>

## Criando a conta e gerando keys [#criando-a-conta-e-gerando-keys]

A conta e as keys nascem no painel. O fluxo abaixo é feito **uma vez**; depois você só gera/revoga keys conforme precisar.

1. **Cadastre-se** (empresa, CNPJ, e-mail e senha) em `app.pepinozap.com.br`. O cadastro **exige confirmação de e-mail** — você recebe um link logo após o envio do formulário.
2. **Confirme o e-mail** pelo link enviado. O login **só é liberado depois da confirmação** — antes disso, tentar entrar não funciona.
3. **Faça login no painel** e vá em **Integrações → API Keys**. No topo de Integrações há um **toggle Teste / Produção** — escolha o modo desejado **antes** de gerar a key.
4. Clique em gerar, dê um **nome** à key (ele só serve para você identificá-la na lista — não aparece para ninguém) e, opcionalmente, defina uma validade.

A **key crua aparece uma única vez**, no momento da criação. Guarde-a em local seguro (um gerenciador de segredos, uma variável de ambiente do seu servidor). **Não há como recuperá-la depois** — o painel só guarda um hash e um prefixo de exibição. Se você perder a key, **revogue-a e crie outra**.

O **modo** da key é o do toggle no momento em que ela foi gerada e fica gravado nela para sempre:

* toggle em **Teste** → key começa com `pzk_test_`;
* toggle em **Produção** → key começa com `pzk_live_`.

<Callout type="warn">
  O **nome** da key é obrigatório. Gerar sem nome retorna `400` com a mensagem
  "nome obrigatório (identifica a key na listagem)". Escolha algo que diga onde a
  key é usada (ex.: `backend-producao`, `integracao-erp`) — isso facilita
  revogar a certa depois.
</Callout>

### Anatomia de uma API key [#anatomia-de-uma-api-key]

Uma key crua tem a forma `pzk_<modo>_<segredo>`, por exemplo:

```
pzk_test_k4m2qz7r3v8n6t1p9s5w0xab
└──┬───┘ └──────────┬───────────┘
 prefixo          segredo (aleatório)
```

* **Prefixo** (`pzk_test_` ou `pzk_live_`): indica o modo. É por ele que a API decide, logo de cara, se a key combina com o host.
* **Segredo**: parte aleatória de alta entropia. É o que você **não** deve expor. O painel mostra apenas um **prefixo de exibição** (o prefixo do modo + os 6 primeiros caracteres do segredo) para você reconhecer a key na lista sem revelá-la inteira.

## Modos: teste e produção [#modos-teste-e-produção]

Uma **única conta**, com dois modos — no mesmo espírito do test/live do Stripe. **Não há conta nem ambiente separado**: é a mesma conta, os mesmos logins. O que muda é o modo da requisição, e o modo é definido pelo **host** da API. A key tem que **casar** com o host, mas **não é ela que decide o modo**.

| Modo         | Base URL da API                    | Prefixo da key | Cobrança                |
| ------------ | ---------------------------------- | -------------- | ----------------------- |
| **Teste**    | `https://sandbox.pepinozap.com.br` | `pzk_test_`    | grátis, com teto diário |
| **Produção** | `https://api.pepinozap.com.br`     | `pzk_live_`    | exige plano ativo       |

### O host decide o modo; a key tem que casar [#o-host-decide-o-modo-a-key-tem-que-casar]

O modo é resolvido pelo **hostname** da URL que você chama:

* host que &#x2A;*começa com `sandbox`** (`sandbox.pepinozap.com.br`) → **modo teste**;
* qualquer outro host (`api.pepinozap.com.br`) → **modo produção**.

Depois de resolver o modo pelo host, a API **confere o prefixo da key**: ela precisa ser do mesmo modo. As combinações válidas e inválidas:

| Host                       | Key            | Resultado                  |
| -------------------------- | -------------- | -------------------------- |
| `sandbox.pepinozap.com.br` | `pzk_test_...` | OK (modo teste)            |
| `api.pepinozap.com.br`     | `pzk_live_...` | OK (modo produção)         |
| `sandbox.pepinozap.com.br` | `pzk_live_...` | `401` — key do modo errado |
| `api.pepinozap.com.br`     | `pzk_test_...` | `401` — key do modo errado |

Quando o prefixo não casa com o host, a resposta é `401 UNAUTHORIZED&#x60; com a mensagem &#x2A;*"chave de API inválida para este ambiente/modo"**. Esse erro é, na prática, o engano mais comum: usar uma key de teste apontando para `api.*`, ou vice-versa. Antes de investigar qualquer outra coisa, confirme que **o prefixo da key bate com o host**.

### Isolamento de dados por modo [#isolamento-de-dados-por-modo]

Os dados são **isolados por modo**. Instâncias, conversas e webhooks criados em **Teste** nunca aparecem em **Produção**, e vice-versa. Consequências práticas:

* Uma instância criada no sandbox **não existe** em produção (consultá-la lá dá `404`).
* Listar keys/instâncias retorna **apenas** as do modo da requisição.
* No painel, o **toggle filtra** o que você vê em Integrações. O CRM (Inbox, Funil) é sempre Produção.

<Callout type="info">
  Se um `GET` por id retornar `404` ("instância não encontrada") mesmo você
  "tendo certeza" de que ela existe, verifique se não está chamando o **host do
  modo errado**. Recurso de teste só aparece no `sandbox.*`; recurso de produção
  só no `api.*`.
</Callout>

### Teste é grátis; produção exige plano [#teste-é-grátis-produção-exige-plano]

O **modo teste é grátis e self-service**: criar instâncias e enviar mensagens **não exigem plano ativo** — você valida a integração inteira sem assinar nada. Para que o teste grátis não vire "produção de graça", ele tem um **teto diário** de **100 mensagens por dia, por Projeto** (enviadas + recebidas; o dia fecha à meia-noite no fuso de São Paulo). Ao ultrapassar o teto, os envios retornam `429 RATE_LIMITED`.

A **produção** exige uma **assinatura ativa no Projeto** para as ações de uso real (conectar número, enviar mensagem) — é o `402` detalhado [mais abaixo](#plano-ativo-erro-402). Resumindo: teste à vontade no sandbox e só pague ao ir para produção.

## Gerando keys pela API (self-service) [#gerando-keys-pela-api-self-service]

Além do botão no painel, você pode gerar, listar e revogar keys **pela API**, sob o grupo `/v1/keys`. Isso é útil para automatizar a rotação de chaves.

<Callout type="warn">
  Gerenciar keys exige **login de usuário** (a sessão do painel, um token de
  usuário) — **uma API key não pode gerar outras**. Se você chamar `/v1/keys`
  autenticando com uma `pzk_...`, recebe `403` com "gestão de chaves requer login
  de usuário". Isso é proposital: limita o estrago caso uma key vaze (ela não
  consegue se "auto-multiplicar"). Os exemplos desta seção assumem o token de
  usuário do painel no `Authorization`.
</Callout>

### Obtendo o token de usuário — `POST /v1/auth/login` [#obtendo-o-token-de-usuário--post-v1authlogin]

No **navegador**, a sessão do painel vive em cookies `httpOnly` (o token não é exposto ao JavaScript). Para automatizar a rotação de keys **do seu backend**, chame o login **server-to-server** pedindo o token no corpo com o header `X-Token-Response: body`:

```bash
curl -X POST https://api.pepinozap.com.br/v1/auth/login \
  -H "Content-Type: application/json" \
  -H "X-Token-Response: body" \
  -d '{ "email": "voce@empresa.com", "password": "sua-senha" }'
```

Resposta `200 OK` com `accessToken` (curto) e `refreshToken`. Use o `accessToken` como `Bearer <TOKEN_DE_USUARIO>` nos exemplos abaixo. **Sem** esse header, o login responde só com a sessão em cookie (o fluxo do painel) — nunca chame o login com credencial de usuário a partir do navegador de uma página pública.

### Criar uma key — `POST /v1/keys` [#criar-uma-key--post-v1keys]

Corpo da requisição:

| Campo           | Tipo   | Obrigatório | Descrição                                                                                  |
| --------------- | ------ | ----------- | ------------------------------------------------------------------------------------------ |
| `name`          | string | sim         | Identifica a key na listagem. Não pode ser vazio.                                          |
| `expiresInDays` | número | não         | Validade em dias a partir de agora (inteiro `>= 1`). Sem este campo, a key **não expira**. |

O **modo** da key gerada (e portanto o prefixo `pzk_test_`/`pzk_live_`) segue o modo da requisição — no painel/API de usuário, o toggle Teste/Produção.

```bash
curl -X POST https://api.pepinozap.com.br/v1/keys \
  -H "Authorization: Bearer <TOKEN_DE_USUARIO>" \
  -H "Content-Type: application/json" \
  -d '{ "name": "backend-producao", "expiresInDays": 90 }'
```

Resposta `201 Created&#x60; — repare no campo &#x2A;*`key`** com a key crua (aparece **só aqui, uma vez**):

```json
{
  "id": "key_abc123",
  "name": "backend-producao",
  "keyPrefix": "pzk_live_k4m2qz",
  "status": "active",
  "lastUsedAt": null,
  "expiresAt": "2026-09-03T12:00:00Z",
  "revokedAt": null,
  "createdAt": "2026-06-05T12:00:00Z",
  "key": "pzk_live_k4m2qz7r3v8n6t1p9s5w0xab"
}
```

* `key` — a key crua completa. **Guarde-a agora**; ela não volta a aparecer.
* `keyPrefix` — o prefixo de exibição (modo + 6 caracteres), seguro de logar/mostrar.
* `status` — `active`, `revoked` ou `expired`, derivado de `revokedAt`/`expiresAt`.
* `expiresAt` — `null` se você não enviou `expiresInDays`.

<Callout type="info">
  Criar uma key em **produção** exige **plano ativo** (gerar key = habilitar uso
  via integração). Sem assinatura, `POST /v1/keys` retorna `402`
  (`PAYMENT_REQUIRED`). Em **teste**, gerar keys é livre. Ver
  [Plano ativo (erro 402)](#plano-ativo-erro-402).
</Callout>

### Listar suas keys — `GET /v1/keys` [#listar-suas-keys--get-v1keys]

Retorna **apenas as keys do modo atual** (o toggle Teste/Produção filtra). A key crua **não** é incluída — só metadados.

```bash
curl https://api.pepinozap.com.br/v1/keys \
  -H "Authorization: Bearer <TOKEN_DE_USUARIO>"
```

Resposta `200 OK`:

```json
{
  "items": [
    {
      "id": "key_abc123",
      "name": "backend-producao",
      "keyPrefix": "pzk_live_k4m2qz",
      "status": "active",
      "lastUsedAt": "2026-06-05T13:40:00Z",
      "expiresAt": "2026-09-03T12:00:00Z",
      "revokedAt": null,
      "createdAt": "2026-06-05T12:00:00Z"
    }
  ]
}
```

O campo `lastUsedAt` ajuda a identificar keys ociosas (candidatas a revogar). Ele é atualizado de forma assíncrona a cada uso, então pode levar alguns instantes para refletir a última chamada.

### Revogar uma key — `DELETE /v1/keys/{id}` [#revogar-uma-key--delete-v1keysid]

Revoga **imediatamente** a key indicada (use o `id`, não o `keyPrefix`). A partir daí, qualquer requisição com aquela key retorna `401`.

```bash
curl -X DELETE https://api.pepinozap.com.br/v1/keys/key_abc123 \
  -H "Authorization: Bearer <TOKEN_DE_USUARIO>"
```

Sucesso: `204 No Content` (sem corpo). Se o `id` não existir, não for do seu Projeto, ou for de outro modo, a resposta é `404` ("chave não encontrada").

<Callout type="warn">
  A revogação é **definitiva e não tem desfazer**. Não dá para "reativar" uma key
  revogada — gere uma nova. Por isso, ao trocar uma chave em produção, **gere a
  nova e atualize sua aplicação primeiro**, e só então revogue a antiga, evitando
  uma janela sem key válida.
</Callout>

## Como enviar a API key [#como-enviar-a-api-key]

Em **todas** as chamadas REST, envie a key no header `Authorization` com o esquema `Bearer`:

```bash
curl https://sandbox.pepinozap.com.br/v1/instances \
  -H "Authorization: Bearer pzk_test_xxxxxxxxxxxxxxxx"
```

Exemplo com `fetch` (JavaScript):

```javascript
const res = await fetch('https://sandbox.pepinozap.com.br/v1/instances', {
  headers: {
    Authorization: 'Bearer pzk_test_xxxxxxxxxxxxxxxx',
  },
});
```

Pontos de atenção ao montar o header:

* O esquema &#x2A;*`Bearer ` (com um espaço)** é obrigatório. Mandar a key "pelada", sem `Bearer`, é tratado como ausência de Authorization → `401`.
* A key **não** vai na URL nem no corpo — só no header (exceção: o SSE de QR, a seguir).
* Use sempre **HTTPS**. A key é um segredo de acesso total ao Projeto.

## Fallback `?token=` (apenas para SSE) [#fallback-token-apenas-para-sse]

O endpoint de QR Code via stream (`GET /v1/instances/{id}/qr`) usa **Server-Sent Events**. O `EventSource` do navegador **não envia headers customizados**, então não há como mandar o `Authorization` nesse caso. Para esse endpoint — e **somente para ele** — a API aceita a credencial na **query string**, no parâmetro `?token=`.

Há duas formas de preencher o `?token=`:

1. **A própria key crua** (mais simples). Funciona, mas coloca a key na URL:

   ```bash
   curl -N "https://sandbox.pepinozap.com.br/v1/instances/{id}/qr?token=pzk_test_xxxxxxxxxxxxxxxx"
   ```

2. **Um token efêmero de SSE** (recomendado para o navegador). Gere com `POST /v1/instances/{id}/qr-token` e use o token retornado no `?token=`. Esse token é &#x2A;*curto (validade \~2 min)** e **escopado a uma única instância** (só vale na rota de QR daquela instância), limitando o impacto se vazar em um log ou no `Referer`.

```javascript
const id = 'inst_...';
const token = 'pzk_test_xxxxxxxxxxxxxxxx'; // ou o token efêmero de qr-token

const source = new EventSource(
  `https://sandbox.pepinozap.com.br/v1/instances/${id}/qr?token=${token}`,
);
```

<Callout type="warn">
  Utilize `?token=` **exclusivamente** no endpoint SSE de QR. Nas demais
  chamadas, use sempre o header `Authorization` — a query string aparece em logs
  de servidor, proxies e no `Referer`, então expor a key crua ali é arriscado.
  Para o QR no navegador, prefira o **token efêmero** de `qr-token` justamente por
  isso. O passo a passo do pareamento está em
  [Sua primeira instância](/docs/get-started/first-instance#parear-via-qr-code).
</Callout>

## Erros esperados [#erros-esperados]

Todos os erros da API seguem o **mesmo envelope**:

```json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "explicação legível do que aconteceu"
  }
}
```

* `code` — rótulo estável, derivado do status HTTP. Use-o para tratar o erro no seu código (é mais confiável que comparar a `message`).
* `message` — texto legível, em português, que diz **o que** deu errado.

Os status que a autenticação pode retornar:

| Status | `code`             | Quando ocorre                                                                                                                |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `401`  | `UNAUTHORIZED`     | Key ausente, malformada, inválida, revogada, expirada, **ou do modo errado** para o host.                                    |
| `402`  | `PAYMENT_REQUIRED` | O Projeto não tem plano ativo (ou créditos da API zerados). Ver [Plano ativo](#plano-ativo-erro-402).                        |
| `403`  | `FORBIDDEN`        | Projeto **inativo** (key válida, mas o Projeto não está `ACTIVE`); ou gestão de keys tentada sem login de usuário.           |
| `429`  | `RATE_LIMITED`     | **Apenas no modo teste:** teto diário do Projeto atingido (100 mensagens/dia, enviadas + recebidas). Em produção não ocorre. |

### Detalhando o `401 UNAUTHORIZED` [#detalhando-o-401-unauthorized]

O `401` cobre várias causas. A `message` distingue cada uma — vale tratá-las separadamente ao depurar:

| `message`                                       | Causa                                                                                         | Como resolver                                                                                                  |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `Authorization Bearer ou ?token= ausente`       | Nenhuma credencial foi enviada (faltou o header, ou veio sem `Bearer`).                       | Envie `Authorization: Bearer <key>`.                                                                           |
| `chave de API inválida para este ambiente/modo` | O prefixo da key **não bate com o host** (`pzk_live_` no sandbox ou `pzk_test_` na produção). | Use a key do modo correto para o host (ver [O host decide o modo](#o-host-decide-o-modo-a-key-tem-que-casar)). |
| `token ou chave de API inválido`                | A key não corresponde a nenhuma key existente (digitada errada, truncada, ou inexistente).    | Confira se copiou a key inteira. Se perdeu a key, gere outra.                                                  |
| `chave de API revogada`                         | A key foi revogada (no painel ou via `DELETE /v1/keys/{id}`).                                 | Gere uma key nova.                                                                                             |
| `chave de API expirada`                         | A key passou da validade definida em `expiresInDays`.                                         | Gere uma key nova (sem `expiresInDays`, ou com prazo maior).                                                   |

Exemplo de resposta `401`:

```json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "chave de API inválida para este ambiente/modo"
  }
}
```

<Callout type="info">
  Há um `401` específico do **painel** (login de usuário), não da API key: o
  `code` `SESSION_SUPERSEDED`, emitido quando a mesma conta é aberta em outro
  dispositivo (a sessão única invalida a anterior). **API keys não têm sessão** e
  nunca recebem esse erro — ele só aparece em chamadas autenticadas com o token de
  usuário do painel. Ao recebê-lo, faça login de novo.
</Callout>

### O `403 FORBIDDEN` [#o-403-forbidden]

O `403` significa que a credencial **é válida**, mas a ação não é permitida no estado atual:

* **Projeto inativo** — a key é reconhecida, mas o Projeto dela não está com status `ACTIVE` (suspenso, por exemplo). `message`: "projeto não está ativo". A regularização é feita pelo time PEPINO; entre em contato.
* **Gestão de keys sem login de usuário** — você tentou `/v1/keys` autenticando com uma API key em vez do token de usuário. `message`: "gestão de chaves requer login de usuário". Use a sessão do painel.

Exemplo de resposta `403`:

```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "projeto não está ativo"
  }
}
```

## Plano ativo (erro 402) [#plano-ativo-erro-402]

Ter uma API key válida **não basta** para operar em produção. As ações de **uso real** — conectar um número e enviar mensagens — exigem uma **assinatura ativa no Projeto** (modelo "pague antes, use depois"). Como a key é escopada a um Projeto, a regra vale igualmente para o painel e para a integração via API. **No modo teste, esse erro nunca ocorre** (teste é grátis).

Só a assinatura com status &#x2A;*`active`** libera. Qualquer outro estado (sem assinatura, `pending`, `paused`, `past_due`, `canceled`) resulta em `402`.

Endpoints que retornam `402` sem plano ativo (em produção):

* `POST /v1/instances` — criar instância;
* `POST /v1/instances/{id}/messages` e os demais envios sob `/v1/instances/{id}/messages/` (`/media`, `/media-upload`, `/location`, `/contact`, `/sticker`, `/reaction`, `/poll`);
* `POST /v1/keys` — gerar API key (habilitar uso via integração).

Endpoints de **leitura** (listar/detalhar instâncias e keys), o **pareamento por QR Code** e **marcar como lida** continuam liberados mesmo sem plano ativo — você consegue inspecionar o estado, mas não produzir tráfego.

Exemplo de resposta `402`:

```json
{
  "error": {
    "code": "PAYMENT_REQUIRED",
    "message": "É necessário um plano ativo para usar este recurso. Assine em \"Meu plano\"."
  }
}
```

<Callout type="info">
  **Como resolver:** assine um plano em **Meu plano**, no painel. Assim que a
  assinatura fica `active`, as chamadas voltam a funcionar **sem alterar uma
  linha de código** — não é preciso gerar nova key. Projetos de cortesia,
  marcados como isentos pelo time PEPINO, nunca recebem este erro.
</Callout>

### Créditos da API insuficientes [#créditos-da-api-insuficientes]

Além da falta de plano, o `402` também aparece por **créditos zerados**. Projetos no modo **API** consomem **créditos pré-pagos** a cada mensagem **enviada pela API** (envios feitos pelo painel **não** consomem créditos). Quando o saldo zera, os endpoints de **envio** passam a retornar `402` com `code` `PAYMENT_REQUIRED`, e a `message` indica créditos insuficientes.

A resolução é **recarregar créditos** em **Meu plano**. &#x2A;*Distinga os dois casos `402` pela `message`:** falta de plano ativo (texto sobre assinar) versus falta de créditos (texto sobre recarregar) — a ação a tomar é diferente em cada um.

### Add-on de campanhas (disparo em massa) [#add-on-de-campanhas-disparo-em-massa]

As rotas de **campanhas** (`/v1/campaigns`, criação/disparo) têm uma exigência extra: além do plano ativo, o Projeto precisa do &#x2A;*add-on "Disparo em massa"**. Sem ele, criar/disparar campanha retorna `402` com a mensagem orientando a ativar o add-on em **Meu plano**. Leitura de campanhas é livre, e no **modo teste** o add-on não é exigido. (Detalhes na documentação de campanhas.)

## Boas práticas de segurança [#boas-práticas-de-segurança]

* **Trate a key como senha.** Quem tiver a key tem acesso total ao Projeto. Não a coloque em repositórios, front-end, prints ou tickets de suporte.
* **Use variáveis de ambiente / cofre de segredos** no servidor — nunca hardcode a key no código.
* **Uma key por integração.** Dê nomes claros (ex.: `backend-prod`, `worker-fila`). Assim, se uma vazar, você revoga só ela sem derrubar as outras.
* **Defina validade** (`expiresInDays`) em keys de vida curta (scripts, testes pontuais) para que expirem sozinhas.
* **Rotacione com janela de transição:** gere a nova key, atualize a aplicação, confirme que funciona e só então revogue a antiga.
* **Comece pelo modo teste.** Valide a integração inteira no `sandbox.*` com uma `pzk_test_` antes de gerar a `pzk_live_` e ligar a produção.

## Próximos passos [#próximos-passos]

* [Quick start](/docs/get-started/quick-start) — a primeira chamada de ponta a ponta.
* [Criar a primeira instância](/docs/get-started/first-instance) — criar, parear por QR Code e configurar uma instância.


# Sua primeira instância (/docs/get-started/first-instance)





Uma **instância** representa uma conexão com o WhatsApp dentro do seu Projeto — na prática, um número de telefone conectado. Cada instância tem um `id` próprio (prefixo `inst_`), um `status` que descreve em que ponto do ciclo de vida ela está, e, depois de pareada, o número (`phoneNumber`) e o `jid` (o endereço do contato no WhatsApp). Você pode ter mais de uma instância por Projeto, até o limite de números do seu plano.

Este guia cobre o ciclo de vida completo: **criar** a instância, **parear** com o WhatsApp lendo o QR Code (por polling — recomendado — ou por Server-Sent Events), **consultar** o estado atual, **remover** e **tratar os erros** de cada etapa.

## Autenticação e modos [#autenticação-e-modos]

Todas as requisições exigem o header `Authorization: Bearer <API_KEY>`. A **única exceção** é o endpoint de QR via **SSE**, que autentica via `?token=<API_KEY>` na query string, porque o `EventSource` do navegador não envia headers (detalhado em [Alternativa: stream SSE](#alternativa-stream-sse)). O endpoint de QR via **polling** usa o header normalmente.

O **host** decide o modo, e a key precisa casar com ele:

| Modo     | Host                               | Prefixo da key |
| -------- | ---------------------------------- | -------------- |
| Produção | `https://api.pepinozap.com.br`     | `pzk_live_`    |
| Teste    | `https://sandbox.pepinozap.com.br` | `pzk_test_`    |

Uma key de teste contra o host de produção (ou vice-versa) retorna `401`. Os exemplos abaixo usam a URL de produção; troque o host e o prefixo da key para validar no sandbox.

<Callout type="info">
  Instâncias são **isoladas por modo**: uma instância criada no teste não
  aparece nem responde nas chamadas de produção, e vice-versa. Tentar acessar
  uma instância do outro modo retorna `404` (como se ela não existisse) — não é
  um vazamento, é o isolamento esperado.
</Callout>

## Ciclo de vida [#ciclo-de-vida]

O `status` da instância é um enum de **seis valores**:

| Status         | Significado                                                          | Operacional? |
| -------------- | -------------------------------------------------------------------- | ------------ |
| `DISCONNECTED` | Estado inicial após a criação e sempre que a conexão não está ativa. | Não          |
| `CONNECTING`   | A instância está abrindo a conexão com o WhatsApp.                   | Não          |
| `QR_PENDING`   | Aguardando o pareamento. Um QR Code está disponível para leitura.    | Não          |
| `CONNECTED`    | Pareada e operacional. Mensagens podem ser enviadas.                 | **Sim**      |
| `BANNED`       | Número bloqueado pela plataforma do WhatsApp. Estado terminal.       | Não          |
| `ERROR`        | Falha irrecuperável. Estado terminal.                                | Não          |

O caminho feliz, do zero ao número operando, percorre os quatro primeiros em ordem:

```
DISCONNECTED  →  CONNECTING  →  QR_PENDING  →  CONNECTED
```

Logo após a criação, a instância nasce em `DISCONNECTED`. Quando você abre o fluxo de pareamento (qualquer um dos endpoints de QR abaixo), ela passa por `CONNECTING` e chega a `QR_PENDING`, expondo o QR Code. Assim que o QR é lido no aparelho, ela vai para `CONNECTED` — e só então aceita envio de mensagens.

<Callout type="info">
  **`CONNECTED` = pareamento concluído de verdade.** A instância só entra em `CONNECTED`
  depois que o QR é escaneado e o WhatsApp confirma o vínculo (o evento interno `PairSuccess`)
  — **nunca** só por estar abrindo a conexão para exibir o QR. Por isso, ao ver `CONNECTED`
  (no `GET /v1/instances/{id}` ou no webhook `instance.status`), pode confiar: a sessão está
  ativa e o número pronto para enviar. Enquanto o QR não é lido, a instância fica em
  `QR_PENDING`/`DISCONNECTED`, **não** em `CONNECTED`.
</Callout>

<Callout type="info">
  Uma vez pareada, a instância **reconecta automaticamente** após quedas,
  reinícios ou deploys, **sem exigir novo QR Code** — a sessão é persistida. A
  reconexão automática **não** ocorre nos estados terminais `BANNED` (número
  bloqueado pelo WhatsApp) e `ERROR` (falha irrecuperável). Se o número for
  **deslogado pelo próprio usuário no aparelho** (em "Aparelhos conectados"), a
  instância retorna para `DISCONNECTED` e um novo pareamento é necessário.
</Callout>

<Callout type="warn">
  Enviar mensagem para uma instância em `BANNED` ou `ERROR` retorna `409`
  (`instance is not operational`) — o envio nem entra na fila. Para uma
  instância em `DISCONNECTED`, `CONNECTING` ou `QR_PENDING`, o envio é aceito,
  mas só sai depois que ela chegar a `CONNECTED`. Confirme `status: "CONNECTED"`
  antes de disparar mensagens.
</Callout>

## Listar instâncias [#listar-instâncias]

Para ver todas as instâncias do Projeto no modo atual:

```bash
curl https://api.pepinozap.com.br/v1/instances \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK` com um array (vazio `[]` se você ainda não criou nenhuma):

```json
[
  {
    "id": "inst_abc123",
    "projectId": "proj_xyz789",
    "name": "minha-instancia",
    "status": "CONNECTED",
    "phoneNumber": "5511999999999",
    "jid": "5511999999999@s.whatsapp.net",
    "settings": {
      "rejectCalls": false,
      "rejectCallMessage": "",
      "ignoreGroups": false,
      "alwaysOnline": false,
      "readReceipts": false
    },
    "createdAt": "2026-05-30T12:00:00Z",
    "updatedAt": "2026-05-30T12:00:00Z"
  }
]
```

A lista traz **apenas as instâncias do modo da key usada** (teste ou produção).

## Criar uma instância [#criar-uma-instância]

O único campo do corpo é o `name`:

| Campo  | Tipo   | Obrigatório | Descrição                                                                                                          |
| ------ | ------ | ----------- | ------------------------------------------------------------------------------------------------------------------ |
| `name` | string | Sim         | Um rótulo seu para identificar a instância no painel e nas listagens. Pode repetir entre instâncias (não é único). |

```bash
curl -X POST https://api.pepinozap.com.br/v1/instances \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "minha-instancia"}'
```

Resposta `201 Created`:

```json
{
  "id": "inst_abc123",
  "projectId": "proj_xyz789",
  "name": "minha-instancia",
  "status": "DISCONNECTED",
  "phoneNumber": null,
  "jid": null,
  "settings": {
    "rejectCalls": false,
    "rejectCallMessage": "",
    "ignoreGroups": false,
    "alwaysOnline": false,
    "readReceipts": false
  },
  "createdAt": "2026-05-30T12:00:00Z",
  "updatedAt": "2026-05-30T12:00:00Z"
}
```

O que cada campo significa logo na criação:

* `id` — &#x2A;*guarde este valor.** É ele que identifica a instância em todas as demais chamadas (QR, consulta, envio, configurações, remoção).
* `status` — sempre `DISCONNECTED` no nascimento; só muda quando você inicia o pareamento.
* `phoneNumber` e `jid` — vêm `null` enquanto a instância não estiver pareada. São preenchidos automaticamente após o `CONNECTED` (o `phoneNumber` é o número em formato E.164 sem `+`, e o `jid` é o endereço completo no WhatsApp, ex.: `5511999999999@s.whatsapp.net`).
* `settings` — começa com todas as opções desligadas (ver [Configurações da instância](#configuracoes-da-instancia)).

<Callout type="info">
  Criar a instância exige um **plano ativo** no Projeto (em produção). Sem
  assinatura, este endpoint retorna `402` (`PAYMENT_REQUIRED`) — assine em **Meu
  plano**, no painel. No **modo teste**, a criação é grátis e self-service (não
  exige plano). O pareamento por QR Code abaixo não exige plano em nenhum dos
  modos. Ver [Plano ativo](/docs/get-started/authentication#plano-ativo-erro-402).
</Callout>

### Erros ao criar [#erros-ao-criar]

O envelope de erro é `{ "error": { "code", "message" } }`.

| Código | Quando acontece                                                                                                                |
| ------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | Corpo inválido — JSON malformado ou `name` ausente/vazio.                                                                      |
| `401`  | API key ausente ou inválida (inclui key do modo errado para o host).                                                           |
| `402`  | O Projeto não tem plano ativo (`PAYMENT_REQUIRED`). Assine em **Meu plano**.                                                   |
| `409`  | **Cota de números do plano atingida.** Contrate mais um número em **Meu plano**. (Não é nome duplicado — nomes podem repetir.) |

## Parear via QR Code [#parear-via-qr-code]

O pareamento conecta o número do WhatsApp à instância. O fluxo é: você abre um dos endpoints de QR, isso pede ao serviço para gerar um QR Code **na hora**, você renderiza esse código na sua interface, e o usuário o lê no aparelho em **WhatsApp → Aparelhos conectados → Conectar um aparelho**. Assim que o aparelho confirma, a instância vai para `CONNECTED`.

Há **duas formas** de obter o QR Code. **Recomendamos o polling** (`GET /v1/instances/{id}/qr/poll`): cada requisição é curta e atravessa antivírus e proxies corporativos sem problemas. O stream SSE (`GET /v1/instances/{id}/qr`) é uma alternativa, porém alguns antivírus e proxies bufferizam streams e impedem a entrega do QR Code.

<Callout type="info">
  O QR Code **expira em segundos** e é **rotacionado** — um novo código é
  emitido periodicamente enquanto a instância está em `QR_PENDING`. Por isso,
  **sempre renderize o código mais recente** que você receber (no polling, o
  `qr.code` da última resposta; no SSE, o último evento `qr`). Não fixe o
  primeiro QR na tela.
</Callout>

### Polling (recomendado) [#polling-recomendado]

Faça `GET /v1/instances/{id}/qr/poll` a cada \~2 segundos. Cada chamada faz duas coisas: pede ao worker que conecte a instância **agora** (idempotente — não faz nada se já houver conexão, e revive o QR se o ciclo anterior expirou) e devolve o snapshot atual.

O corpo da resposta (`200 OK`) tem dois campos:

| Campo       | Tipo                            | Descrição                                                                                                                                                                                        |
| ----------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `connected` | boolean                         | `true` quando a instância já está conectada (pareamento concluído — **pare o polling**).                                                                                                         |
| `qr`        | objeto `{ code, at }` ou `null` | Enquanto não conectada, traz o QR atual: `code` (a string para renderizar como QR Code) e `at` (timestamp de emissão). Vem `null` quando ainda não há QR disponível ou quando já está conectada. |

Exemplos de resposta — antes e depois do pareamento:

```json
// ainda aguardando leitura
{
  "connected": false,
  "qr": {
    "code": "2@A1b2C3d4E5f6...",
    "at": "2026-05-30T12:00:03Z"
  }
}
```

```json
// pareamento concluído — pare o polling
{
  "connected": true,
  "qr": null
}
```

```javascript
const id = 'inst_abc123';
const apiKey = 'pzk_live_...';

const timer = setInterval(async () => {
  const res = await fetch(`https://api.pepinozap.com.br/v1/instances/${id}/qr/poll`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  const { connected, qr } = await res.json();

  if (connected) {
    clearInterval(timer); // pareamento concluído
    return;
  }
  if (qr) {
    // Renderize "qr.code" como QR Code usando uma biblioteca de QR.
    renderQrCode(qr.code);
  }
}, 2000);
```

<Callout type="info">
  Diferente do SSE, o polling usa o header `Authorization: Bearer <API_KEY>`
  normalmente. Como cada requisição é curta e independente, ele funciona mesmo
  em redes com antivírus (Avast, Kaspersky, ESET) ou proxies corporativos que
  bufferizam streams — a causa mais comum de "o QR Code não aparece".
</Callout>

### Alternativa: stream SSE [#alternativa-stream-sse]

O endpoint `GET /v1/instances/{id}/qr` transmite **Server-Sent Events** (`Content-Type: text/event-stream`). Em vez de você consultar repetidamente, o servidor empurra os eventos conforme acontecem. Os eventos são:

| Evento   | Payload                                 | Descrição                                                                                              |
| -------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `ready`  | `{ "instanceId": "inst_..." }`          | Evento inicial, indicando que o stream foi aberto. Aguarde o primeiro `qr` em seguida.                 |
| `qr`     | `{ "code", "at" }`                      | Cada QR novo emitido. Renderize o campo `code` como um QR Code para leitura.                           |
| `status` | `{ "status", "jid?", "reason?", "at" }` | Emitido a cada transição de estado. Veja o vocabulário abaixo.                                         |
| `ping`   | *(vazio)*                               | Keepalive, emitido aproximadamente a cada 25s para manter a conexão viva através de proxies. Ignore-o. |

<Callout type="warn">
  Pelo **SSE**, o campo `status` usa o vocabulário "rico" do worker — **diferente
  e maior** que o enum de 6 valores acima. Trate esse vocabulário, não o enum:
</Callout>

| `status` (SSE)    | Significado                                           | Como tratar                        |
| ----------------- | ----------------------------------------------------- | ---------------------------------- |
| `CONNECTED`       | Conexão estabelecida.                                 | **Sucesso** — feche o stream.      |
| `PAIRED`          | Pareou (equivale a conectado).                        | **Sucesso** — feche o stream.      |
| `DISCONNECTED`    | Conexão caiu (pode reconectar sozinha se já pareada). | Aguarde ou gere novo QR.           |
| `LOGGED_OUT`      | Deslogado (ex.: removido no aparelho).                | **Falha** — exige novo pareamento. |
| `STREAM_REPLACED` | Outra sessão assumiu a conexão.                       | **Falha** — encerre.               |
| `TEMPORARY_BAN`   | Bloqueio temporário do número pela plataforma.        | **Falha** — encerre.               |
| `PAIR_TIMEOUT`    | Ninguém escaneou o QR a tempo.                        | **Falha** — gere um novo QR.       |
| `PAIR_ERROR`      | Erro durante o pareamento.                            | **Falha** — gere um novo QR.       |

Trate `PAIRED`/`CONNECTED` como sucesso e `PAIR_TIMEOUT`/`PAIR_ERROR`/`LOGGED_OUT`/`STREAM_REPLACED`/`TEMPORARY_BAN` como falha/encerramento. **Não** faça um `switch` estrito só nos 6 valores do enum de status: o stream pareceria "travado" esperando um `CONNECTED` que, num timeout/erro, nunca chega. O **polling** acima evita essa complexidade — ele só expõe `connected: true/false`.

Abra o stream, renderize o `code` do evento `qr` e, quando o evento `status` reportar `CONNECTED` ou `PAIRED`, o pareamento foi concluído.

#### Token efêmero (não exponha a API key na URL) [#token-efêmero-não-exponha-a-api-key-na-url]

Como o `EventSource` autentica pela query string (`?token=`), passar a API key crua na URL a expõe em logs e no histórico do navegador. Para evitar isso, emita um **token efêmero** com `POST /v1/instances/{id}/qr-token` e use-o no `?token=`.

```bash
curl -X POST https://api.pepinozap.com.br/v1/instances/$INSTANCE_ID/qr-token \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK`:

```json
{ "token": "eyJhbGciOiJI..." }
```

O token é **válido por \~2 minutos** e serve **apenas** para abrir o stream de QR **dessa instância** — não dá acesso ao resto da API. Se o pareamento demorar mais que isso, emita um novo token e reabra o stream.

```javascript
const id = 'inst_abc123';

// Emita o token efêmero (em vez de expor a API key na URL).
const tokenRes = await fetch(`https://api.pepinozap.com.br/v1/instances/${id}/qr-token`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${apiKey}` },
});
const { token } = await tokenRes.json();

const url = `https://api.pepinozap.com.br/v1/instances/${id}/qr?token=${token}`;
const source = new EventSource(url);

source.addEventListener('ready', () => {
  // Stream aberto; aguardando o primeiro evento "qr".
});

source.addEventListener('qr', (event) => {
  const { code } = JSON.parse(event.data);
  // Renderize "code" como QR Code usando uma biblioteca de QR.
  renderQrCode(code);
});

source.addEventListener('status', (event) => {
  const { status } = JSON.parse(event.data);
  if (status === 'CONNECTED' || status === 'PAIRED') {
    source.close(); // pareado com sucesso
  } else if (['PAIR_TIMEOUT', 'PAIR_ERROR', 'LOGGED_OUT', 'STREAM_REPLACED', 'TEMPORARY_BAN'].includes(status)) {
    source.close(); // falhou/encerrou — gere um novo QR ou trate o erro
  }
});
```

<Callout type="info">
  O `EventSource` do navegador não envia o header `Authorization`. Por isso, o
  endpoint SSE de QR autentica pela query string `?token=<API_KEY>` (ou pelo
  token efêmero do `qr-token`). Em **todos os demais endpoints**, use sempre
  `Authorization: Bearer <API_KEY>`.
</Callout>

### Erros ao parear [#erros-ao-parear]

| Código | Endpoint              | Quando acontece                                                                                   |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------- |
| `401`  | `qr/poll`, `qr-token` | API key ausente ou inválida (ou do modo errado para o host).                                      |
| `401`  | `qr` (SSE)            | `token` ausente, inválido ou expirado na query string.                                            |
| `404`  | todos                 | Instância não encontrada — não existe, não é do seu Projeto, ou é do outro modo (teste/produção). |

O pareamento **não exige plano ativo** — você pode gerar e ler o QR Code mesmo sem assinatura. O que exige plano é criar a instância e enviar mensagens.

## Consultar e remover [#consultar-e-remover]

### Consultar [#consultar]

Verifique o estado atual da instância a qualquer momento — é assim que você confirma se o pareamento chegou ao `CONNECTED` e lê o `phoneNumber`/`jid` preenchidos:

```bash
curl https://api.pepinozap.com.br/v1/instances/$INSTANCE_ID \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK` com a instância completa:

```json
{
  "id": "inst_abc123",
  "projectId": "proj_xyz789",
  "name": "minha-instancia",
  "status": "CONNECTED",
  "phoneNumber": "5511999999999",
  "jid": "5511999999999@s.whatsapp.net",
  "settings": {
    "rejectCalls": false,
    "rejectCallMessage": "",
    "ignoreGroups": false,
    "alwaysOnline": false,
    "readReceipts": false
  },
  "createdAt": "2026-05-30T12:00:00Z",
  "updatedAt": "2026-05-30T12:05:00Z"
}
```

Consultar **não exige plano ativo** — leitura é sempre liberada.

### Remover [#remover]

Para remover uma instância (retorna `204 No Content`, sem corpo):

```bash
curl -X DELETE https://api.pepinozap.com.br/v1/instances/$INSTANCE_ID \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

A remoção **não exige plano ativo**. Ela desfaz o pareamento e libera a vaga de número na sua cota — depois disso, o `id` não responde mais (uma consulta posterior retorna `404`). Se você quiser reconectar o mesmo número, crie uma nova instância e pareie de novo.

<Callout type="warn">
  Tanto **consultar** quanto **remover** retornam `404` se o `id` não existir,
  for de outro Projeto ou for do **outro modo** (uma key de teste não enxerga
  uma instância de produção, e vice-versa). Se você está certo de que o `id`
  existe e mesmo assim recebe `404`, confira se o **host e o prefixo da key**
  batem com o modo da instância.
</Callout>

## Configurações da instância [#configurações-da-instância]

Cada instância tem um conjunto de **configurações de comportamento** — pequenos liga/desliga que mudam como o número conectado reage a chamadas, grupos, presença e leitura. São totalmente **opcionais**: por padrão **todas vêm desligadas** (o comportamento neutro do WhatsApp), e você liga só as que fizerem sentido para o seu caso.

São cinco configurações, resumidas aqui e detalhadas logo abaixo:

| Campo               | Tipo    | Padrão  | Em uma frase                                                                                                          |
| ------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------- |
| `rejectCalls`       | boolean | `false` | Rejeita automaticamente chamadas (voz/vídeo) recebidas.                                                               |
| `rejectCallMessage` | string  | `""`    | Mensagem enviada a quem ligou, logo após a rejeição (só com `rejectCalls: true`).                                     |
| `ignoreGroups`      | boolean | `false` | Ignora mensagens de grupo (sem webhook, sem WebSocket, sem conversa no CRM).                                          |
| `alwaysOnline`      | boolean | `false` | Mantém o número aparecendo "online" enquanto conectado.                                                               |
| `readReceipts`      | boolean | `false` | Marca mensagens recebidas como lidas automaticamente (tique azul para o remetente).                                   |
| `importHistory`     | boolean | `false` | Ao parear, importa o histórico de conversas que o WhatsApp libera (persistido, sem mídia). Desligado, começa do zero. |

### Como atualizar [#como-atualizar]

Use `PATCH /v1/instances/{id}/settings`. É um **patch parcial**: envie **apenas os campos que quer alterar** — os que você omitir permanecem com o valor atual (não são zerados). Para ligar só uma opção, mande só ela.

```bash
curl -X PATCH https://api.pepinozap.com.br/v1/instances/$INSTANCE_ID/settings \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rejectCalls": true,
    "rejectCallMessage": "No momento não atendemos chamadas. Envie uma mensagem que respondemos por aqui.",
    "ignoreGroups": true,
    "alwaysOnline": false,
    "readReceipts": true
  }'
```

A resposta é `200 OK` com a **instância completa**, já trazendo o objeto `settings` atualizado:

```json
{
  "id": "inst_abc123",
  "projectId": "proj_xyz789",
  "name": "minha-instancia",
  "status": "CONNECTED",
  "phoneNumber": "5511999999999",
  "jid": "5511999999999@s.whatsapp.net",
  "settings": {
    "rejectCalls": true,
    "rejectCallMessage": "No momento não atendemos chamadas. Envie uma mensagem que respondemos por aqui.",
    "ignoreGroups": true,
    "alwaysOnline": false,
    "readReceipts": true
  },
  "createdAt": "2026-05-30T12:00:00Z",
  "updatedAt": "2026-06-05T12:00:00Z"
}
```

As mudanças são **persistidas** e aplicadas ao número em **poucos segundos** (no próximo ciclo de sincronização do worker). Continuam valendo após quedas, reconexões e deploys — você **não** precisa reenviar a cada conexão.

### O que cada configuração faz [#o-que-cada-configuração-faz]

#### `rejectCalls` — rejeitar chamadas (boolean, padrão `false`) [#rejectcalls--rejeitar-chamadas-boolean-padrão-false]

Quando `true`, **toda chamada de voz ou vídeo recebida é rejeitada automaticamente**, na hora, sem tocar. Ideal para números que são só de mensagens (atendimento, notificações). Quando `false`, as chamadas tocam normalmente. Para avisar quem ligou, combine com `rejectCallMessage`.

#### `rejectCallMessage` — resposta automática à chamada (string, padrão `""`) [#rejectcallmessage--resposta-automática-à-chamada-string-padrão-]

Texto **enviado automaticamente como mensagem para quem ligou**, logo depois de a chamada ser rejeitada. &#x2A;*Só tem efeito quando `rejectCalls: true`** (é ignorado se as chamadas não estão sendo rejeitadas). Deixe vazio para rejeitar sem mandar nada.

<Callout type="info">
  Exemplo prático: ligue `rejectCalls` e defina `rejectCallMessage` como "No
  momento não atendemos chamadas. Envie uma mensagem que respondemos por aqui."
  — assim, quem tentar ligar recebe a orientação na mesma hora.
</Callout>

#### `ignoreGroups` — ignorar grupos (boolean, padrão `false`) [#ignoregroups--ignorar-grupos-boolean-padrão-false]

Quando `true`, **mensagens recebidas em grupos são ignoradas pela plataforma**: não disparam webhook nem WebSocket, e não criam nem atualizam conversa no CRM (Inbox). Use quando você só quer tratar conversas individuais (1:1) e não ser inundado pelo tráfego de grupos. **Não** afeta mensagens que **você** envia para grupos pela API. Quando `false`, os grupos são processados normalmente.

#### `alwaysOnline` — sempre online (boolean, padrão `false`) [#alwaysonline--sempre-online-boolean-padrão-false]

Quando `true`, o número &#x2A;*aparece sempre "online"** para os contatos enquanto a instância estiver conectada (a presença "disponível" é reanunciada a cada conexão). Quando `false`, a presença segue o comportamento normal do WhatsApp. Não altera a entrega de mensagens — muda só a indicação de presença que os contatos veem.

#### `readReceipts` — confirmação de leitura automática (boolean, padrão `false`) [#readreceipts--confirmação-de-leitura-automática-boolean-padrão-false]

Quando `true`, **cada mensagem recebida é marcada como lida automaticamente*&#x2A; assim que a plataforma a processa — o remetente vê o &#x2A;*"lido" (tique azul)**. Quando `false`, as mensagens **não** são marcadas como lidas (o remetente não vê o tique azul), mesmo que você já as tenha recebido pelo webhook. Ligue se quer dar confirmação de leitura automática aos contatos.

#### `importHistory` — importar histórico ao conectar (boolean, padrão `false`) [#importhistory--importar-histórico-ao-conectar-boolean-padrão-false]

Quando `true`, **ao parear a instância** a plataforma **importa o histórico de conversas** que o WhatsApp libera no pareamento e o persiste — você passa a lê-lo pelos endpoints de [Inbox](/docs/api). Quando `false` (padrão), a instância **começa do zero**: captura só as mensagens novas dali pra frente (conecta e carrega mais rápido). Para trazer conversas anteriores **sob demanda** depois (sem ligar o import no pareamento), use `POST /v1/inbox/conversations/{id}/history` — ele puxa o lote anterior de **uma** conversa específica.

<Callout type="info">
  **Limite do WhatsApp:** mesmo ligado, **não** é "todo o histórico desde sempre"
  — o WhatsApp envia uma **janela recente** para um aparelho recém-pareado. O
  histórico importado entra **sem mídia** (só texto/metadados; os anexos antigos
  não são baixados, para não pesar), e as mensagens importadas **não** disparam
  webhook (`message.received`) — elas só populam o histórico do Inbox.
</Callout>

### Como desligar uma configuração [#como-desligar-uma-configuração]

Como é patch parcial, para **desligar** uma opção envie-a com o valor desligado — `false` (booleanos) ou `""` (o texto de rejeição):

```bash
curl -X PATCH https://api.pepinozap.com.br/v1/instances/$INSTANCE_ID/settings \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rejectCalls": false, "rejectCallMessage": "" }'
```

### Erros possíveis [#erros-possíveis]

O envelope de erro é `{ "error": { "code", "message" } }`.

* `400` — corpo JSON inválido (por exemplo, um campo com o tipo errado).
* `401` — API key ausente ou inválida.
* `403` — projeto inativo.
* `404` — instância não encontrada (não existe, não é do seu Projeto, ou é de outro modo — teste/produção).

A referência completa, com todos os campos e o playground "Try it", está em [API Reference: configurações da instância](/docs/api/instancias/updateInstanceSettings).

## Erros por endpoint [#erros-por-endpoint]

Todos os erros usam o envelope `{ "error": { "code", "message" } }`. Resumo por etapa deste guia:

| Código | Significado                                                                                                                                      | Onde aparece                          |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- |
| `400`  | Corpo inválido (JSON malformado, `name` ausente, tipo errado).                                                                                   | criar, configurações                  |
| `401`  | API key ausente/inválida, ou token SSE inválido/expirado.                                                                                        | todos                                 |
| `402`  | `PAYMENT_REQUIRED` — Projeto sem plano ativo. Assine em **Meu plano**. Ver [Plano ativo](/docs/get-started/authentication#plano-ativo-erro-402). | criar (produção), enviar              |
| `403`  | Projeto inativo ou sem permissão.                                                                                                                | configurações, e demais               |
| `404`  | Instância não encontrada (não existe, é de outro Projeto ou de outro modo).                                                                      | consultar, remover, QR, configurações |
| `409`  | **Cota de números do plano atingida** ao criar; ou instância **não operacional** (`BANNED`/`ERROR`) ao enviar.                                   | criar, enviar                         |

## Próximos passos [#próximos-passos]

* [Autenticação](/docs/get-started/authentication) — como usar a API key e os modos teste/produção.
* [Configurações da instância (API Reference)](/docs/api/instancias/updateInstanceSettings) — referência completa do `PATCH` de settings.
* [Enviar mensagens](/docs/api/mensagens/sendTextMessage) — envio de texto e mídia após o pareamento.
* [Webhooks](/docs/webhooks) — receba eventos de mensagens e de status da instância.


# Persistência e histórico (/docs/get-started/persistencia)





O PEPINO ZAP **persiste todo o histórico** das suas conversas (texto e mídia) na nossa infraestrutura. Para o integrador isso significa uma coisa direta: **você não precisa manter um banco de dados nem um storage de mídia próprios** só para o WhatsApp. Nós somos o *system of record*; você lê o histórico pela API quando precisar.

Esta página explica **o que é guardado**, a diferença entre **histórico persistido e efêmero**, **como ler** o histórico pela API e o **impacto no Inbox/conversas**. Os exemplos usam a URL de produção `https://api.pepinozap.com.br`; no modo teste, troque para `https://sandbox.pepinozap.com.br` (com uma key `pzk_test_`). Todas as requisições exigem o header `Authorization: Bearer <API_KEY>`, e a key precisa **casar com o host** — ver [Autenticação](/docs/get-started/authentication#modos-teste-e-producao).

## O que guardamos [#o-que-guardamos]

Tudo que passa pelo seu número conectado é gravado e fica vinculado ao seu **Projeto**, escopado por modo (teste/produção):

| Dado                                                      | Onde fica          | Você precisa guardar? |
| --------------------------------------------------------- | ------------------ | --------------------- |
| Conversas (uma por contato)                               | Nosso Postgres     | Não                   |
| Mensagens (enviadas e recebidas)                          | Nosso Postgres     | Não                   |
| Contatos                                                  | Nosso Postgres     | Não                   |
| Mídia recebida (imagem, áudio, vídeo, documento, sticker) | Nosso storage (S3) | Não                   |

<Callout type="info">
  **Teste e produção são separados.** O histórico de uma key `pzk_test_` (host `sandbox.*`) e o de
  uma key `pzk_live_` (host `api.*`) vivem em espaços isolados. Uma conversa criada em teste **não**
  aparece nas consultas de produção, e vice-versa. Veja [Modos: teste e
  produção](/docs/get-started/authentication#modos-teste-e-producao).
</Callout>

<Callout type="warn">
  **Retenção de mídia: 2 anos.** O **texto** das mensagens fica no histórico indefinidamente; o
  **binário da mídia** (imagem, áudio, vídeo, documento, sticker) é guardado por **2 anos** e,
  depois disso, é **removido automaticamente** da nossa infraestrutura — a mensagem permanece (texto
  e metadados), mas sem o arquivo. Se você precisa da mídia por mais tempo, **baixe e armazene no
  seu lado** quando o evento chega (a `mediaUrl` é pré-assinada e expira; ver
  [Eventos](/docs/webhooks/events)).
</Callout>

## O histórico sobrevive a deletar a instância [#o-histórico-sobrevive-a-deletar-a-instância]

Apagar (ou desconectar) uma instância remove apenas a **sessão do WhatsApp** daquele número — o **histórico permanece**:

| Ação                        | O que acontece                                                                                                                                               |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Desconectar** a instância | Nada é apagado. A instância e a sessão ficam; reconecta sozinha. Histórico intacto.                                                                          |
| **Deletar** a instância     | Remove a sessão (pareamento) do WhatsApp. As **conversas, mensagens e contatos permanecem** — eles são vinculados ao **Projeto + contato**, não à instância. |

Como as conversas são chaveadas pelo &#x2A;*contato (número)**, ao criar uma instância nova e parear o mesmo número, o histórico daquele número continua disponível pela API. Para **enviar** mensagens você precisa de uma instância conectada; para **ler** o histórico, não.

<Callout type="info">
  Mesmo depois de deletar a instância de origem, a conversa e suas mensagens **continuam
  acessíveis** — eles são chaveados pelo **Projeto + contato**, não pela instância. O `instanceId`
  que a conversa carrega é apenas a referência de por onde ela entrou; deletar a instância não apaga
  o histórico.
</Callout>

## Persistido vs. efêmero [#persistido-vs-efêmero]

Cada Projeto tem um modo de histórico. O padrão — e o recomendado para a maioria das integrações — é **persistido**. Existe também um modo **efêmero**, usado em cenários específicos (por exemplo, números de atendimento onde só interessa o que está em aberto *agora*, sem manter um arquivo durável visível no Inbox).

| Modo                    | O que você vê no Inbox                                                                                                                                                                            | Os dados são apagados? |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| **Persistido** (padrão) | **Todas** as conversas do Projeto, sempre — independentemente de a instância estar conectada ou ter sido deletada.                                                                                | Não.                   |
| **Efêmero**             | **Apenas*&#x2A; as conversas cuja instância está &#x2A;*`CONNECTED`** no momento da consulta. Ao desconectar a instância, as conversas dela **somem da listagem**; ao reconectar, **reaparecem**. | Não.                   |

<Callout type="warn">
  **Efêmero NÃO apaga nada.** As mensagens e conversas continuam gravadas no nosso banco em ambos os
  modos — o que muda é só **o que aparece na listagem do Inbox**. No modo efêmero, conversas de
  instâncias não-conectadas ficam *ocultas* enquanto a instância está fora; voltam a aparecer assim
  que ela fica `CONNECTED` de novo. Não é exclusão, é um filtro de visibilidade.
</Callout>

O estado `CONNECTED` é o da instância pareada e operacional — ver o [ciclo de vida da instância](/docs/get-started/first-instance#ciclo-de-vida). Qualquer outro estado (`DISCONNECTED`, `CONNECTING`, `QR_PENDING`, `BANNED`, `ERROR`) faz, no modo efêmero, as conversas daquela instância saírem da listagem até a reconexão.

### O que muda na prática [#o-que-muda-na-prática]

* **Listagem de conversas** (`GET /v1/inbox/conversations`) e **contadores das abas** (todas/abertas/pendentes/encerradas): no modo efêmero, ambos refletem **só** as conversas de instâncias `CONNECTED`. No persistido, refletem tudo.
* **Detalhe e mensagens de uma conversa** (`GET /v1/inbox/conversations/{id}` e `/messages`): respondem normalmente desde que você tenha o `id` da conversa — esses endpoints não aplicam o filtro de modo. Em outras palavras, mesmo no modo efêmero, se você guardou o `id`, ainda lê o histórico daquela conversa específica.
* **Webhooks e WebSocket**: o modo de histórico **não** muda a entrega de eventos em tempo real. Mensagens recebidas continuam disparando `message.received` normalmente nos dois modos.

### Qual é o padrão e como alterar [#qual-é-o-padrão-e-como-alterar]

O modo padrão de **todo Projeto novo é persistido**. Em caso de erro transitório ao carregar a configuração do Projeto, a API assume **persistido** por segurança — para nunca esconder histórico por engano.

O modo de histórico é uma **configuração do Projeto** definida na criação dele. **Não há endpoint público** (key `pzk_live_`/`pzk_test_`) para o integrador alternar entre persistido e efêmero — não é um liga/desliga self-service como as [configurações da instância](/docs/get-started/first-instance#configuracoes-da-instancia). Para revisar ou mudar o modo do seu Projeto, fale com o suporte.

<Callout type="info">
  Para a esmagadora maioria das integrações, **persistido** (o padrão) é o que você quer: o Inbox
  vira um arquivo durável e você não precisa de banco próprio. Só considere o efêmero se o seu caso
  de uso for explicitamente "mostrar apenas o que está vivo agora".
</Callout>

## Como ler o histórico [#como-ler-o-histórico]

Use os endpoints de **Inbox** (autenticados pela mesma API key). Eles devolvem o histórico persistido, paginado, escopado ao seu Projeto e ao modo (teste/produção) da key. O Inbox lista **apenas conversas 1-a-1** — grupos (`@g.us`), listas de transmissão (`@broadcast`) e canais (`@newsletter`) não aparecem.

### Listar conversas [#listar-conversas]

`GET /v1/inbox/conversations` devolve uma conversa por contato, ordenada por atividade (mais recente primeiro), **30 por página**. Aceita `?status=` (`open`, `pending` ou `resolved`; vazio = todas) e `?page=` (padrão `1`).

```bash
curl "https://api.pepinozap.com.br/v1/inbox/conversations" \
  -H "Authorization: Bearer $YOUR_API_KEY"

# filtrando por status e paginando
curl "https://api.pepinozap.com.br/v1/inbox/conversations?status=open&page=2" \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK`. O bloco `counts` traz os totais por status **de todo o Projeto** (não só da página/filtro atual), úteis para os contadores das abas:

```json
{
  "items": [
    {
      "id": "conv_abc123",
      "instanceId": "inst_xyz789",
      "protocol": "2026-000042",
      "contactId": "cont_def456",
      "contactName": "Maria Silva",
      "contactPhone": "5511999999999",
      "contactJid": "5511999999999@s.whatsapp.net",
      "status": "open",
      "assignedUserId": null,
      "unread": 2,
      "lastMessageAt": "2026-06-05T13:40:00Z",
      "lastMessageText": "Perfeito, obrigado!"
    }
  ],
  "page": 1,
  "counts": {
    "all": 37,
    "open": 12,
    "pending": 5,
    "resolved": 20
  }
}
```

| Campo                               | Significado                                                                                                                                                                                                                                 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`                                | Identificador da conversa. Guarde-o para ler as mensagens (abaixo).                                                                                                                                                                         |
| `instanceId`                        | Instância pela qual a conversa entrou. Pode ser `null` em conversas que não estão vinculadas a uma instância. A conversa permanece acessível mesmo que a instância referenciada tenha sido deletada (o histórico não depende da instância). |
| `protocol`                          | Número de protocolo do ticket no Projeto, no formato `ano-sequência` (`null` se não houver).                                                                                                                                                |
| `contactName`                       | Nome do contato. Pode ser `null` se ainda não foi resolvido/definido.                                                                                                                                                                       |
| `contactPhone` / `contactJid`       | Número e JID do WhatsApp do contato.                                                                                                                                                                                                        |
| `status`                            | `open`, `pending` ou `resolved`.                                                                                                                                                                                                            |
| `assignedUserId`                    | Atendente atribuído (`null` se não atribuída).                                                                                                                                                                                              |
| `unread`                            | Mensagens não-lidas.                                                                                                                                                                                                                        |
| `lastMessageAt` / `lastMessageText` | Carimbo e prévia da última mensagem (ambos podem ser `null` em conversa sem mensagens).                                                                                                                                                     |

### Ler as mensagens de uma conversa [#ler-as-mensagens-de-uma-conversa]

`GET /v1/inbox/conversations/{id}/messages` devolve as mensagens em **ordem cronológica*&#x2A; (mais antiga → mais nova), em páginas. Por padrão traz as **`limit` mais recentes** (`limit` padrão `50`, máximo `200`). Para o histórico completo (scrollback), pagine **para trás** com o cursor `before`.

```bash
# página mais recente (até 50 mensagens)
curl "https://api.pepinozap.com.br/v1/inbox/conversations/conv_abc123/messages" \
  -H "Authorization: Bearer $YOUR_API_KEY"

# página anterior (mais antigas): use o nextBefore da resposta anterior
curl "https://api.pepinozap.com.br/v1/inbox/conversations/conv_abc123/messages?before=msg_000123&limit=100" \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK`:

```json
{
  "items": [
    {
      "id": "msg_000122",
      "direction": "INBOUND",
      "type": "text",
      "text": "Bom dia, tudo bem?",
      "mediaUrl": null,
      "mediaMime": null,
      "status": "READ",
      "waMessageId": "3EB0XXXXXXXXXXXX",
      "quotedWaMessageId": null,
      "createdAt": "2026-06-05T13:38:00Z"
    },
    {
      "id": "msg_000123",
      "direction": "OUTBOUND",
      "type": "image",
      "text": "Segue a tabela",
      "mediaUrl": "https://s3.amazonaws.com/.../arquivo.jpg?X-Amz-Signature=...",
      "mediaMime": "image/jpeg",
      "status": "DELIVERED",
      "waMessageId": "3EB0YYYYYYYYYYYY",
      "quotedWaMessageId": null,
      "createdAt": "2026-06-05T13:40:00Z"
    }
  ],
  "hasMore": true,
  "nextBefore": "msg_000122"
}
```

| Campo                               | Significado                                                                                                                                  |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `direction`                         | `INBOUND` (recebida) ou `OUTBOUND` (enviada por você).                                                                                       |
| `type`                              | `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `poll` ou `unknown`.                                        |
| `text`                              | Texto/legenda da mensagem (`null` quando não houver).                                                                                        |
| `mediaUrl`                          | URL pré-assinada (validade \~6h), só presente em mensagens de mídia. Releia o endpoint para obter uma nova.                                  |
| `mediaMime`                         | Content-type da mídia (`null` se não for mídia).                                                                                             |
| `status`                            | Estado de entrega da mensagem. Para `OUTBOUND` evolui por `SENT`, `DELIVERED` e `READ`; as recebidas (`INBOUND`) chegam já como `DELIVERED`. |
| `waMessageId` / `quotedWaMessageId` | Id da mensagem no WhatsApp e da mensagem citada (resposta), se houver.                                                                       |

**Paginando o histórico completo:** repita a chamada passando o `nextBefore` da resposta anterior em `?before=` até `hasMore` virar `false`. Quando `hasMore` é `false`, `nextBefore` vem `null` — você chegou ao início da conversa.

Veja o contrato completo, com playground "Try it", em [Lista as conversas](/docs/api/inbox/listInboxConversations) e [Lista as mensagens](/docs/api/inbox/listInboxMessages).

<Callout type="info">
  **Mídia: re-hospedada por nós (por 2 anos).** O binário fica no nosso storage por **2 anos**;
  depois disso é removido (o **texto** da mensagem permanece). As mensagens de mídia trazem uma
  `mediaUrl` **pré-assinada** gerada na hora da leitura (validade \~6h). Se a URL expirar, **basta
  reler o endpoint de mensagens** para obter uma nova — dentro da janela de 2 anos você não precisa
  baixar nem armazenar o arquivo. (A `mediaUrl` que chega no webhook `message.received` é gerada com
  validade de **24h** e serve para uso imediato.) &#x2A;*Se precisar da mídia por mais de 2 anos, baixe e
  armazene do seu lado.**
</Callout>

### Erros possíveis (Inbox) [#erros-possíveis-inbox]

O envelope de erro é `{ "error": { "code", "message" } }`, em que `code` é um **rótulo string** derivado do status HTTP (`UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`).

| Status | `code`         | Significado                                                                                               |
| ------ | -------------- | --------------------------------------------------------------------------------------------------------- |
| `401`  | `UNAUTHORIZED` | API key ausente ou inválida — inclui usar a key no host do modo errado (ex.: `pzk_live_` em `sandbox.*`). |
| `403`  | `FORBIDDEN`    | Projeto inativo ou sem permissão.                                                                         |
| `404`  | `NOT_FOUND`    | Conversa não encontrada (não existe, não é do seu Projeto, ou é de outro modo — teste/produção).          |

## Tempo real, sem polling [#tempo-real-sem-polling]

Para reagir a mensagens novas e a mudanças de status **na hora**, use eventos em vez de ficar consultando a API:

* **Webhook** — entrega garantida com retry (cadastrado por você via `POST /v1/webhooks`, self-service). Ver [Webhooks](/docs/webhooks).
* **WebSocket** — self-service, conecta com a sua API key. Ver [WebSocket](/docs/webhooks/websocket).

O Inbox é a fonte do **histórico**; os eventos são para o **tempo real**. Juntos, cobrem todo o ciclo sem você manter banco próprio. O modo de histórico (persistido/efêmero) **não** altera a entrega desses eventos.

## Migrando de uma stack própria [#migrando-de-uma-stack-própria]

Se você hoje mantém infra própria para o WhatsApp (por exemplo, um banco de mensagens, um bucket de mídia e um canal de realtime), ao integrar o PEPINO ZAP você pode **descontinuar essas três camadas**:

| Sua camada atual                          | Substituto no PEPINO ZAP                                    |
| ----------------------------------------- | ----------------------------------------------------------- |
| Banco de mensagens (ex.: DynamoDB/SQL)    | `GET /v1/inbox/conversations` + `/messages`                 |
| Storage de mídia (ex.: bucket S3)         | Nosso storage (mídia retida 2 anos) + `mediaUrl` na leitura |
| Realtime (ex.: WebSocket/pub-sub próprio) | Webhook `message.received`/`message.status` ou WebSocket    |

<Callout type="warn">
  Guardar uma cópia própria continua sendo **opção sua** (redundância, relatórios offline, retenção
  mais longa) — mas **não é requisito**. Para a maioria das integrações, ler do Inbox quando
  precisar é suficiente.
</Callout>

## Próximos passos [#próximos-passos]

* [Sua primeira instância](/docs/get-started/first-instance) — criar, parear e configurar a instância.
* [Autenticação](/docs/get-started/authentication) — API key, modos teste/produção e o fallback `?token=`.
* [Lista as conversas](/docs/api/inbox/listInboxConversations) — referência completa do Inbox.
* [Webhooks](/docs/webhooks) — receba eventos de mensagens e de status da instância em tempo real.


# Quick start (/docs/get-started/quick-start)





O PEPINO ZAP é uma API REST que integra o WhatsApp ao seu sistema. Este guia apresenta o **caminho mínimo ponta a ponta** para colocar uma integração no ar:

1. [Obter a API key](#1-obtenha-a-api-key)
2. [Criar uma instância](#2-crie-uma-instancia)
3. [Parear via QR Code](#3-pareie-via-qr-code)
4. [Enviar a primeira mensagem](#4-envie-a-primeira-mensagem)
5. [Receber mensagens e status](#5-receba-mensagens-e-status)

Cada passo traz o `curl` (ou o snippet equivalente), a resposta JSON real e os erros que você pode encontrar. Os exemplos usam o ambiente de **teste** (sandbox); para produção, basta trocar a base URL e a API key (veja abaixo). Não é preciso ler nada antes — mas, se quiser o contexto de autenticação e modos, ele está em [Autenticação](/docs/get-started/authentication).

## Conceitos em 30 segundos [#conceitos-em-30-segundos]

Três palavras se repetem ao longo do guia:

* **Projeto** — a unidade que sua API key representa. Instâncias, conversas, webhooks e a assinatura/créditos pertencem todos a um Projeto. Tudo que você cria com uma key vive dentro do Projeto dela.
* **Instância** — uma conexão de WhatsApp dentro do Projeto (um número pareado). Você pode ter mais de uma, conforme a cota do plano.
* **Modo (teste/produção)** — uma única conta, dois ambientes isolados, decididos pelo **host**. O sandbox é grátis (com teto); a produção exige plano ativo. Dados de um modo nunca aparecem no outro.

## Base URL e variáveis [#base-url-e-variáveis]

Defina a base URL e a API key uma única vez no shell — os exemplos abaixo reaproveitam essas variáveis.

```bash
export BASE_URL="https://sandbox.pepinozap.com.br"
export API_KEY="pzk_test_xxxxxxxxxxxxxxxx"
```

| Modo     | Base URL                           | Prefixo da key | Cobrança                         |
| -------- | ---------------------------------- | -------------- | -------------------------------- |
| Teste    | `https://sandbox.pepinozap.com.br` | `pzk_test_`    | grátis, com teto de 100 msgs/dia |
| Produção | `https://api.pepinozap.com.br`     | `pzk_live_`    | exige plano ativo                |

<Callout type="warn">
  **O host decide o modo; a key tem que casar com o host.** Quem define se a
  requisição é teste ou produção é o **hostname**: `sandbox.*` é teste, `api.*` é
  produção. A key precisa ter o prefixo do modo do host — `pzk_test_` em
  `sandbox.*` e `pzk_live_` em `api.*`. Usar a key do modo errado (por exemplo,
  uma `pzk_live_` contra o `sandbox.*&#x60;) é rejeitado com &#x2A;*`401`**. Detalhes em
  [Modos: teste e produção](/docs/get-started/authentication#modos-teste-e-producao).
</Callout>

Todas as chamadas REST levam o header `Authorization: Bearer <API_KEY>`. A **única** exceção é o stream de QR via SSE, que autentica por `?token=` (explicado no passo 3) porque o `EventSource` do navegador não envia headers customizados.

### Envelope de erro [#envelope-de-erro]

Sempre que algo dá errado, a resposta segue o mesmo formato — guarde este shape, ele se repete em todos os passos:

```json
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "API key ausente ou inválida"
  }
}
```

Use o `code` (estável, para `switch` no seu código) e exiba a `message` (legível, em PT-BR) para o usuário/log.

## 1. Obtenha a API key [#1-obtenha-a-api-key]

A geração de keys é **self-service** — você não depende de ninguém para começar:

1. **Cadastre-se** (empresa, CNPJ, e-mail e senha) em `app.pepinozap.com.br`. O cadastro exige confirmação de e-mail.
2. **Confirme o e-mail** pelo link enviado. O login só libera depois disso.
3. No painel, vá em **Integrações → API Keys**. No topo há um **toggle Teste / Produção** — escolha **Teste** para seguir este guia e gere a key.

A key crua aparece **uma única vez**, no momento da criação — copie e guarde em variável de ambiente (não há como recuperá-la depois; se perder, revogue e crie outra). O modo da key é o do toggle no instante em que ela foi gerada: Teste → `pzk_test_`, Produção → `pzk_live_`.

O passo a passo completo está em [Autenticação](/docs/get-started/authentication).

### Confira que a key funciona [#confira-que-a-key-funciona]

Antes de seguir, faça uma chamada de leitura (não exige plano, não consome nada). Listar instâncias de um Projeto novo devolve um array vazio:

```bash
curl "$BASE_URL/v1/instances" \
  -H "Authorization: Bearer $API_KEY"
```

Resposta `200`:

```json
[]
```

Se você receber `401`, a key está ausente/inválida ou não casa com o host (veja o Callout acima). Se receber qualquer JSON sem `error`, a autenticação está ok.

<Callout type="info">
  **Plano ativo só é exigido em produção, e só para usar de verdade.** No **modo
  teste** (sandbox) criar instâncias e enviar mensagens é **grátis** — você valida
  a integração inteira sem assinar. Em **produção**, criar instância e enviar
  exigem um **plano ativo** no Projeto; sem assinatura, esses endpoints retornam
  `402` (`PAYMENT_REQUIRED`). Leitura, QR e marcar como lida ficam sempre
  liberados. Ver [Plano ativo](/docs/get-started/authentication#plano-ativo-erro-402).
</Callout>

## 2. Crie uma instância [#2-crie-uma-instância]

Uma instância representa uma conexão de WhatsApp dentro do seu Projeto. Criá-la é só registrar o slot — ela nasce **desconectada**; o pareamento com o número vem no passo 3.

```bash
curl -X POST "$BASE_URL/v1/instances" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "minha-instancia" }'
```

| Campo  | Tipo   | Obrigatório | Descrição                                                                                                        |
| ------ | ------ | ----------- | ---------------------------------------------------------------------------------------------------------------- |
| `name` | string | Sim         | Rótulo livre para você identificar a instância. **Pode repetir** — não é único; serve só para você se organizar. |

Resposta `201 Created`:

```json
{
  "id": "cm5x8a9k20001abc123def456",
  "projectId": "cm5x8a9k20000abc123def000",
  "name": "minha-instancia",
  "status": "DISCONNECTED",
  "phoneNumber": null,
  "jid": null,
  "settings": {
    "rejectCalls": false,
    "rejectCallMessage": "",
    "ignoreGroups": false,
    "alwaysOnline": false,
    "readReceipts": false
  },
  "createdAt": "2026-05-29T22:15:00Z",
  "updatedAt": "2026-05-29T22:15:00Z"
}
```

Campos da resposta:

* `id` — **guarde este valor**: ele identifica a instância em todas as próximas chamadas (substitui o `{id}` nas URLs).
* `projectId` — o Projeto ao qual a instância pertence (o da sua key).
* `status` — começa sempre em `DISCONNECTED`. O ciclo completo é `DISCONNECTED → CONNECTING → QR_PENDING → CONNECTED`; veja [Ciclo de vida](/docs/get-started/first-instance#ciclo-de-vida).
* `phoneNumber` / `jid` — `null` até o pareamento; preenchidos quando a instância conecta.
* `settings` — configurações de comportamento (rejeitar chamadas, ignorar grupos etc.), todas desligadas por padrão. Como ajustar: [Configurações da instância](/docs/get-started/first-instance#configuracoes-da-instancia).

```bash
# Guarde o id para reusar nos próximos passos:
export INSTANCE_ID="cm5x8a9k20001abc123def456"
```

### Erros possíveis na criação [#erros-possíveis-na-criação]

| Status | `code`             | Quando ocorre                                                                                                              |
| ------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `BAD_REQUEST`      | `name` ausente ou body JSON malformado.                                                                                    |
| `401`  | `UNAUTHORIZED`     | Key ausente, inválida ou do modo errado para o host.                                                                       |
| `402`  | `PAYMENT_REQUIRED` | **Só em produção**: Projeto sem plano ativo. Assine em **Meu plano**.                                                      |
| `403`  | `FORBIDDEN`        | Projeto inativo (suspenso).                                                                                                |
| `409`  | `CONFLICT`         | Cota de números do plano atingida — contrate mais um número em **Meu plano**. (Não é nome duplicado; nomes podem repetir.) |

### Como desfazer [#como-desfazer]

Para remover a instância (retorna `204 No Content`, sem corpo):

```bash
curl -X DELETE "$BASE_URL/v1/instances/$INSTANCE_ID" \
  -H "Authorization: Bearer $API_KEY"
```

## 3. Pareie via QR Code [#3-pareie-via-qr-code]

A instância nasceu `DISCONNECTED`. Para conectá-la a um número, você gera um QR Code e o escaneia no app do WhatsApp do aparelho (**Aparelhos conectados → Conectar um aparelho**). Há duas formas de obter esse QR.

**Recomendamos o polling**: cada requisição é curta e atravessa antivírus e proxies corporativos sem problemas. O stream SSE é uma alternativa, porém alguns antivírus e proxies bufferizam streams e impedem a entrega do QR Code. O guia completo (com o vocabulário rico de status do SSE) está em [Sua primeira instância](/docs/get-started/first-instance#parear-via-qr-code).

### Polling (recomendado) [#polling-recomendado]

Faça `GET /v1/instances/{id}/qr/poll` a cada \~2 segundos. Diferente do SSE, esse endpoint usa o header `Authorization` normalmente.

```bash
curl "$BASE_URL/v1/instances/$INSTANCE_ID/qr/poll" \
  -H "Authorization: Bearer $API_KEY"
```

Enquanto não conectado, a resposta `200` traz o QR atual:

```json
{
  "connected": false,
  "qr": {
    "code": "2@AbCdEf123...,Xyz==,...",
    "at": "2026-05-29T22:15:10Z"
  }
}
```

Depois que o número escaneia e pareia:

```json
{
  "connected": true,
  "qr": null
}
```

Como interpretar:

* `connected` — `true` quando o pareamento foi concluído. **Pare o polling** nesse ponto.
* `qr` — objeto `{ code, at }` enquanto não conectado, ou `null&#x60; quando já conectado. Renderize o campo &#x2A;*`code`** como um QR Code (com uma biblioteca de QR) para leitura no aparelho. O `code` muda a cada ciclo: sempre desenhe o `code` mais recente que o poll retornar.

```javascript
const id = process.env.INSTANCE_ID;
const apiKey = process.env.API_KEY;
const BASE_URL = 'https://sandbox.pepinozap.com.br';

const timer = setInterval(async () => {
  const res = await fetch(`${BASE_URL}/v1/instances/${id}/qr/poll`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  const { connected, qr } = await res.json();

  if (connected) {
    clearInterval(timer); // pareamento concluído — pode enviar
    return;
  }
  if (qr) {
    renderQrCode(qr.code); // desenhe "qr.code" como QR Code
  }
}, 2000);
```

<Callout type="info">
  A primeira chamada ao `qr/poll` (e ao stream SSE) **acorda o número**: ela pede
  para a plataforma conectar a instância na hora e gerar o QR, sem esperar o ciclo
  automático. Por isso, na primeiríssima chamada o `qr` pode vir `null` por um ou
  dois segundos — basta continuar o polling que o `code` aparece em seguida.
</Callout>

### Alternativa: stream SSE [#alternativa-stream-sse]

O endpoint `GET /v1/instances/{id}/qr` transmite Server-Sent Events (`Content-Type: text/event-stream`). Como o `EventSource` do navegador não envia o header `Authorization`, este endpoint autentica pela query string `?token=<API_KEY>`:

```bash
curl -N "$BASE_URL/v1/instances/$INSTANCE_ID/qr?token=$API_KEY"
```

O stream emite estes eventos:

* `ready` — evento inicial, indicando que o fluxo foi aberto.
* `qr` — `data` contém `{ code, at }`; renderize o campo `code` como QR Code para leitura.
* `status` — `data` contém `{ status, jid?, reason?, at? }`, refletindo a transição da instância.
* `ping` — keepalive emitido periodicamente (a cada \~25s) para manter a conexão viva atrás de proxies.

<Callout type="warn">
  Pelo **SSE**, o campo `status` usa um vocabulário "rico" — **maior** que o enum
  de 6 valores do ciclo de vida. Trate `PAIRED`/`CONNECTED` como **sucesso** e
  `PAIR_TIMEOUT`/`PAIR_ERROR`/`LOGGED_OUT`/`STREAM_REPLACED`/`TEMPORARY_BAN` como
  **falha/encerramento**. Não faça um `switch` estrito só nos 6 valores do enum: o
  stream pareceria "travado" esperando um `CONNECTED` que, num timeout/erro, nunca
  chega. O **polling** evita essa complexidade — ele só expõe `connected:
    true/false`. A lista completa de status do SSE está em
  [Sua primeira instância](/docs/get-started/first-instance#alternativa-stream-sse).
</Callout>

Para **não expor a API key crua na URL**, emita um token efêmero com `POST /v1/instances/{id}/qr-token` (válido \~2 min, escopado a esta instância) e passe-o no `?token=` em vez da key:

```bash
curl -X POST "$BASE_URL/v1/instances/$INSTANCE_ID/qr-token" \
  -H "Authorization: Bearer $API_KEY"
```

Resposta `200`:

```json
{ "token": "eyJhbGciOi..." }
```

### Confirme a conexão [#confirme-a-conexão]

Após o pareamento, a instância **reconecta sozinha** depois de quedas, reinícios ou deploys, sem novo QR — exceto nos estados terminais `BANNED` e `ERROR`, ou se o número for deslogado no aparelho (volta a `DISCONNECTED`, exigindo novo pareamento). Você pode conferir o estado a qualquer momento:

```bash
curl "$BASE_URL/v1/instances/$INSTANCE_ID" \
  -H "Authorization: Bearer $API_KEY"
```

Com a instância pareada, a resposta `200` agora traz `phoneNumber` e `jid` preenchidos:

```json
{
  "id": "cm5x8a9k20001abc123def456",
  "projectId": "cm5x8a9k20000abc123def000",
  "name": "minha-instancia",
  "status": "CONNECTED",
  "phoneNumber": "5511999999999",
  "jid": "5511999999999@s.whatsapp.net",
  "settings": { "rejectCalls": false, "rejectCallMessage": "", "ignoreGroups": false, "alwaysOnline": false, "readReceipts": false },
  "createdAt": "2026-05-29T22:15:00Z",
  "updatedAt": "2026-05-29T22:16:40Z"
}
```

## 4. Envie a primeira mensagem [#4-envie-a-primeira-mensagem]

Com a instância em `CONNECTED`, envie uma mensagem de texto com `POST /v1/instances/{id}/messages`.

<Callout type="warn">
  **Confirme `CONNECTED` antes de enviar.** Enviar para uma instância que ainda
  não está conectada &#x2A;*também retorna `202`** (`{ queued: true }`), mas a mensagem
  **não sai** — fica em espera indefinida e **não há evento de falha**. Antes de
  enviar, cheque `GET /v1/instances/{id}` (`status === "CONNECTED"`) ou o
  `qr/poll` (`connected === true`); e pare de enviar ao receber `instance.status`
  `DISCONNECTED`/`LOGGED_OUT`.
</Callout>

```bash
curl -X POST "$BASE_URL/v1/instances/$INSTANCE_ID/messages" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "5511999999999",
    "body": "Mensagem de teste"
  }'
```

| Campo          | Tipo    | Obrigatório | Descrição                                                                                                                                   |
| -------------- | ------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `to`           | string  | Sim         | Destinatário. Aceita E.164 só com dígitos (`5511999999999`), com `+` (`+5511999999999`) ou o JID completo (`5511999999999@s.whatsapp.net`). |
| `body`         | string  | Sim         | Texto da mensagem.                                                                                                                          |
| `quotedId`     | string  | Não         | `waMessageId` de uma mensagem para citar (responder).                                                                                       |
| `quotedText`   | string  | Não         | Texto da mensagem citada (reconstrói o preview da citação).                                                                                 |
| `quotedFromMe` | boolean | Não         | `true` se a mensagem citada foi enviada por você.                                                                                           |

Resposta `202 Accepted`:

```json
{ "queued": true, "to": "5511999999999" }
```

<Callout type="info">
  O envio é **assíncrono**. O `202` indica apenas que a mensagem foi
  **enfileirada** — não que foi entregue. A confirmação de entrega e de leitura
  chega depois pelo evento `message.status` (próximo passo). O campo `to` na
  resposta ecoa exatamente o que você enviou.
</Callout>

O mesmo fluxo em JavaScript com `fetch`:

```javascript
const res = await fetch(`${BASE_URL}/v1/instances/${INSTANCE_ID}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ to: '5511999999999', body: 'Mensagem de teste' }),
});

const data = await res.json(); // { queued: true, to: '5511999999999' }
```

### Erros possíveis no envio [#erros-possíveis-no-envio]

| Status | `code`             | Quando ocorre                                                                                                                                |
| ------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `BAD_REQUEST`      | `to` ou `body` ausente, ou body JSON malformado.                                                                                             |
| `401`  | `UNAUTHORIZED`     | Key ausente, inválida ou do modo errado para o host.                                                                                         |
| `402`  | `PAYMENT_REQUIRED` | **Só em produção**: sem plano ativo **ou** sem créditos da API (modo API). Gate de negócio — não repita; assine/recarregue em **Meu plano**. |
| `403`  | `FORBIDDEN`        | Projeto inativo.                                                                                                                             |
| `404`  | `NOT_FOUND`        | Instância não encontrada (não existe, é de outro Projeto, ou é de outro modo — teste/produção).                                              |
| `409`  | `CONFLICT`         | Instância em estado terminal `BANNED` ou `ERROR` — não dá para enviar.                                                                       |
| `429`  | `RATE_LIMITED`     | **Só no sandbox**: teto diário do Projeto atingido (100 msgs/dia, enviadas + recebidas).                                                     |

<Callout type="info">
  Texto é só o começo. Os mesmos `gates` valem para os outros tipos: mídia por URL
  (`/messages/media`), localização (`/messages/location`), contato
  (`/messages/contact`), figurinha (`/messages/sticker`), reação
  (`/messages/reaction`) e enquete (`/messages/poll`). Veja a referência em
  [Mensagens](/docs/api/mensagens/sendTextMessage).
</Callout>

## 5. Receba mensagens e status [#5-receba-mensagens-e-status]

Mensagens recebidas (`message.received`) e confirmações de entrega/leitura (`message.status`), além das mudanças de conexão da instância (`instance.status`), chegam por **eventos** — não por polling da API. Há dois transportes, que entregam os **mesmos** eventos, com o **mesmo** envelope:

| Transporte    | Como funciona                                     | Entrega                                                | Indicado para                                                |
| ------------- | ------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
| **WebSocket** | Seu cliente abre uma conexão e recebe os eventos. | Só enquanto conectado (sem buffer/retry).              | Frontends, dashboards, protótipos, quem não tem URL pública. |
| **Webhook**   | A plataforma faz `POST` assinado no seu endpoint. | **Garantida**: com retry e backoff (até 6 tentativas). | Integrações de backend que exigem durabilidade.              |

Os dois podem ser usados ao mesmo tempo. Ambos são **self-service** via API key.

### Caminho mais rápido para testar: WebSocket [#caminho-mais-rápido-para-testar-websocket]

Não precisa de URL pública nem cadastro. Conecte com a sua key na query (o handshake do WebSocket não permite header `Authorization`) e receba os eventos de **todas as instâncias** do Projeto:

```javascript
const ws = new WebSocket(`wss://sandbox.pepinozap.com.br/v1/ws?token=${API_KEY}`);

ws.onmessage = (e) => {
  const evt = JSON.parse(e.data);
  if (evt.event === 'connected') return; // frame de confirmação inicial
  console.log(evt.event, evt.instanceId, evt.data);
};
```

Logo após conectar, o servidor envia `{ "event": "connected", "projectId": "..." }`. Depois, cada evento chega como um frame de texto com o envelope:

```json
{
  "event": "message.received",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-05-29T22:16:30Z",
  "data": { }
}
```

Detalhes (reconexão com backoff, ciclo de vida) em [WebSocket](/docs/webhooks/websocket).

### Entrega garantida: Webhook [#entrega-garantida-webhook]

Para backend, cadastre um endpoint **via API** e receba o `secret` (`whsec_`) na criação — guarde-o para validar a assinatura de cada entrega:

```bash
curl -X POST "$BASE_URL/v1/webhooks" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "meu-endpoint",
    "url": "https://meusistema.com/webhook/pepinozap",
    "events": []
  }'
```

A plataforma passa a fazer `POST` no seu `url` a cada evento, com os headers `X-Pepino-Event`, `X-Pepino-Webhook-Id` e `X-Pepino-Signature: sha256=<hmac-hex>`. Trate as entregas de forma **idempotente** (o mesmo evento pode chegar mais de uma vez; deduplique pelo `X-Pepino-Webhook-Id`) e responda `2xx`. Veja [Webhooks](/docs/webhooks), [Eventos](/docs/webhooks/events) e [Assinatura](/docs/webhooks/signing).

<Callout type="info">
  Tudo aqui é self-service via API key: gerar a key, criar instância, parear,
  enviar e receber. Em **produção** é preciso um **plano ativo** (senão `402`); no
  **modo teste** você testa de graça, com um teto de 100 msgs/dia por Projeto
  (enviadas + recebidas; acima disso, `429`). Ver
  [Autenticação](/docs/get-started/authentication#modos-teste-e-producao).
</Callout>

## Erros esperados (resumo) [#erros-esperados-resumo]

Os erros seguem o envelope `{ "error": { "code", "message" } }`.

| Status | `code`             | Quando ocorre                                                                                                                  |
| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | `BAD_REQUEST`      | Body inválido (campo obrigatório ausente ou JSON malformado).                                                                  |
| `401`  | `UNAUTHORIZED`     | API key ausente, inválida ou do modo errado para o host.                                                                       |
| `402`  | `PAYMENT_REQUIRED` | Sem plano ativo **ou** sem créditos da API (só em produção). Gate de negócio — não repita; assine/recarregue em **Meu plano**. |
| `403`  | `FORBIDDEN`        | Projeto inativo ou sem permissão.                                                                                              |
| `404`  | `NOT_FOUND`        | Instância não encontrada (inexistente, de outro Projeto ou de outro modo).                                                     |
| `409`  | `CONFLICT`         | Criar instância: cota de números do plano atingida. Enviar: instância `BANNED`/`ERROR`.                                        |
| `429`  | `RATE_LIMITED`     | Só no sandbox: teto diário do Projeto atingido (100 msgs/dia, enviadas + recebidas).                                           |

## Próximos passos [#próximos-passos]

* [Autenticação](/docs/get-started/authentication) — API keys, modos teste/produção e o `402` de plano em detalhe.
* [Sua primeira instância](/docs/get-started/first-instance) — ciclo de vida, pareamento por QR e configurações de comportamento da instância.
* [API Reference: Mensagens](/docs/api/mensagens/sendTextMessage) — envio de texto, mídia, localização, contato, figurinha, reação e enquete.
* [Webhooks: Eventos](/docs/webhooks/events) — o payload de cada evento recebido.


# Entrega e retry (/docs/webhooks/delivery)





Esta página descreve, ponta a ponta, **como o PEPINO ZAP entrega cada evento** ao
endpoint de webhook que você cadastrou: o método e os headers da requisição, o
critério que define sucesso e falha, o timeout, a política de reenvio (retry com
backoff) e por que o seu endpoint precisa ser **idempotente**. É o que você
precisa para construir um receptor de webhook robusto.

Antes de continuar, confira os pré-requisitos:

* Você cadastrou o webhook via `POST /v1/webhooks` (self-service) e guardou o
  `secret` (prefixo `whsec_`). Ver [Webhooks](/docs/webhooks).
* Para validar a assinatura de cada entrega, ver [Assinatura](/docs/webhooks/signing).
* Para a estrutura do envelope e a lista de eventos, ver [Eventos](/docs/webhooks/events).

## A requisição de entrega [#a-requisição-de-entrega]

Cada evento é entregue como uma **única requisição HTTP**:

* **Método:** `POST`.
* **URL:** a `url` que você cadastrou no webhook.
* **Corpo:** o envelope JSON `{ event, instanceId, timestamp, data }` — os
  **bytes crus** desse corpo são o que a assinatura cobre.

Exemplo do que chega ao seu endpoint (ilustrativo — formatado para leitura; o
corpo real é JSON compacto):

```http
POST /webhook/pepinozap HTTP/1.1
Host: meusistema.com
Content-Type: application/json
User-Agent: pepino-zap-webhooks/1
X-Pepino-Event: message.received
X-Pepino-Webhook-Id: wh_abc123
X-Pepino-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

{"event":"message.received","instanceId":"inst_123","timestamp":"2026-05-30T12:00:00Z","data":{ }}
```

### Headers de toda entrega [#headers-de-toda-entrega]

Estes headers acompanham **todas** as entregas, sem exceção:

| Header                | Valor                                     | Para que serve                                                                                                                                    |
| --------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                        | O corpo é sempre JSON.                                                                                                                            |
| `User-Agent`          | `pepino-zap-webhooks/1`                   | Identifica o agente de entrega. Útil para filtrar/logar tráfego do PEPINO ZAP.                                                                    |
| `X-Pepino-Event`      | nome do evento, ex. `message.received`    | Permite rotear o tratamento **sem precisar dar parse no corpo**. É o mesmo valor do campo `event` do envelope.                                    |
| `X-Pepino-Webhook-Id` | identificador do webhook, ex. `wh_abc123` | Identifica **qual webhook cadastrado** originou a entrega. Ver o gotcha de idempotência abaixo.                                                   |
| `X-Pepino-Signature`  | `sha256=<hmac-hex>`                       | HMAC-SHA256 do corpo bruto, calculado com o seu `secret`. Use para validar autenticidade e integridade. Ver [Assinatura](/docs/webhooks/signing). |

<Callout type="warn">
  O `X-Pepino-Webhook-Id` identifica o **webhook cadastrado** (a configuração), não a entrega
  individual. Ele é **constante** para todas as entregas daquele mesmo webhook — não é um número de
  sequência nem um ID único por POST. Para deduplicar entregas repetidas (ver
  [Idempotência](#idempotencia)), use um identificador de **negócio** presente em `data` (por
  exemplo `waMessageId` em `message.received`), não este header sozinho.
</Callout>

## Critério de sucesso e falha [#critério-de-sucesso-e-falha]

O que decide o destino de cada tentativa é, exclusivamente, **o status HTTP que o
seu endpoint responde** (ou a ausência de resposta):

* **Sucesso:*&#x2A; o endpoint responde com status na faixa &#x2A;*`2xx`** (`200`–`299`). A
  entrega é considerada concluída, o campo `lastSuccessAt` do webhook é
  atualizado e **não há reenvio**.
* **Falha:** qualquer **outra coisa** — status `4xx` ou `5xx`, **timeout**, ou
  **erro de rede** (DNS, conexão recusada, TLS, etc.). A tentativa é marcada como
  falha (registra `lastFailureAt` e `lastFailureReason`) e o evento entra em
  reenvio, conforme a política de [retry](#retry-e-backoff).

<Callout type="warn">
  Redirecionamentos (`3xx`) **são seguidos** automaticamente (até o limite padrão de redirects), e o
  que decide sucesso/falha é o status da resposta **final**: se a cadeia terminar em `2xx`, a
  entrega é considerada concluída. Ainda assim, aponte o webhook diretamente para a **URL final** do
  seu endpoint, sem redirect intermediário (por exemplo, cadastre a URL `https` já de cara) — cada
  salto consome tempo do timeout de 15s e passa pela mesma checagem anti-SSRF, e um redirect que não
  possa ser seguido vira falha.
</Callout>

O corpo da sua resposta é **ignorado** — não precisa retornar nenhum JSON. O que
importa é o código de status. Por isso, a resposta ideal é simplesmente:

```http
HTTP/1.1 200 OK
```

### Responda rápido, processe depois [#responda-rápido-processe-depois]

Valide a assinatura, **responda `2xx` imediatamente** e processe a carga de forma
**assíncrona** (fila interna, job, etc.). Não bloqueie a resposta enquanto grava
no banco, chama outra API ou faz qualquer trabalho lento — se você demorar além
do timeout, a entrega vira falha e o **mesmo evento** será reenviado, mesmo que o
seu processamento já tenha começado.

## Timeout [#timeout]

Cada tentativa de entrega tem **15 segundos** de limite. Se o seu endpoint não
**responder por completo** (headers + status) dentro desse intervalo, a tentativa
é abortada, tratada como falha e entra em reenvio.

<Callout type="info">
  O timeout de 15s vale para a tentativa **inteira**: resolução de DNS, handshake TLS, envio do
  corpo e recebimento da sua resposta. Em endpoints atrás de cold-start (funções serverless que
  "dormem"), a primeira entrega após um período ocioso pode estourar esse limite e cair em retry —
  mantenha o endpoint quente ou garanta que ele responda `2xx` antes de qualquer trabalho pesado.
</Callout>

## Retry e backoff [#retry-e-backoff]

Quando uma tentativa falha, o evento é **reenviado automaticamente** com **backoff
exponencial**, até o limite de **6 tentativas no total** (a entrega inicial + até
5 reenvios). Esgotadas as 6 tentativas, o evento **deixa de ser reenviado** e a
última falha fica registrada no webhook (`lastFailureAt` / `lastFailureReason`).

O intervalo entre as tentativas cresce a cada falha. A tabela abaixo mostra a
janela aproximada (o atraso real recebe um &#x2A;*jitter de ±10%** para evitar picos
sincronizados):

| Após falhar a tentativa | Próxima tentativa | Atraso aproximado                    |
| ----------------------- | ----------------- | ------------------------------------ |
| 1ª (entrega inicial)    | 2ª                | \~1 s                                |
| 2ª                      | 3ª                | \~16 s                               |
| 3ª                      | 4ª                | \~1 min 21 s                         |
| 4ª                      | 5ª                | \~4 min 16 s                         |
| 5ª                      | 6ª                | \~10 min 25 s                        |
| 6ª                      | —                 | sem novo reenvio (evento descartado) |

Na prática, um endpoint indisponível recebe as 6 tentativas ao longo de **cerca
de 16 minutos** antes de o evento ser descartado. Isso cobre quedas curtas
(deploy, reinício, pico de carga) sem perder o evento, mas **não** é uma fila de
retenção de longo prazo: se o seu endpoint ficar fora por mais que essa janela, os
eventos daquele período **não** são reentregues depois.

<Callout type="warn">
  Não há reentrega manual nem "replay" de eventos descartados. Se o seu endpoint ficar indisponível
  além da janela de retry, trate a lacuna pelo seu lado — por exemplo, reconciliando o estado via
  API (`GET /v1/instances/{id}` para conexão, ou consultando suas próprias mensagens). Para
  protótipos e telas ao vivo, o [WebSocket](/docs/webhooks/websocket) é um transporte paralelo, mas
  entrega apenas enquanto o cliente está conectado (sem retry).
</Callout>

### Quando não há retry [#quando-não-há-retry]

Há um caso em que a entrega falha e **não** é reenviada: quando a `url`
cadastrada é **inválida** (não forma uma requisição HTTP válida). Como nenhum
número de tentativas resolveria isso, a entrega é cancelada de imediato e a falha
fica registrada em `lastFailureReason`. Cadastre uma URL `http(s)` absoluta e
válida ao criar o webhook — a criação já valida o formato (ver os
[erros](#erros-ao-cadastrar-o-webhook)).

## Observabilidade: acompanhe a saúde do webhook [#observabilidade-acompanhe-a-saúde-do-webhook]

Cada tentativa atualiza o estado do webhook, que você consulta a qualquer momento
com `GET /v1/webhooks`:

```bash
curl https://api.pepinozap.com.br/v1/webhooks \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK`:

```json
{
  "items": [
    {
      "id": "wh_abc123",
      "name": "meu-endpoint",
      "url": "https://meusistema.com/webhook/pepinozap",
      "events": [],
      "enabled": true,
      "lastSuccessAt": "2026-06-05T12:00:00Z",
      "lastFailureAt": "2026-06-05T11:40:00Z",
      "lastFailureReason": "HTTP 500",
      "createdAt": "2026-05-30T12:00:00Z"
    }
  ]
}
```

Campos úteis para diagnóstico:

* `lastSuccessAt` — instante da última entrega bem-sucedida (`2xx`). `null` se
  ainda não houve nenhuma.
* `lastFailureAt` — instante da última tentativa que falhou. `null` se nunca falhou.
* `lastFailureReason` — motivo legível da última falha, por exemplo `HTTP 500`,
  `HTTP 404`, `erro de rede: ...` ou `URL inválida: ...` (truncado em 500
  caracteres). É o primeiro lugar a olhar quando "o webhook parou de chegar".

<Callout type="info">
  `lastSuccessAt` e `lastFailureAt` são **independentes**: cada um reflete o último evento do seu
  tipo. Um webhook saudável que teve uma falha isolada no passado continuará mostrando aquele
  `lastFailureAt` antigo — compare as duas datas para saber o que aconteceu **por último**.
</Callout>

### Log de entregas (tentativa a tentativa) [#log-de-entregas-tentativa-a-tentativa]

Os campos `last*` acima são um **resumo** (a última entrega de cada tipo). Para o
**histórico completo** — útil para depurar "qual evento falhou, com qual status, quantas
vezes" — use `GET /v1/webhooks/{id}/deliveries`:

```bash
curl "https://api.pepinozap.com.br/v1/webhooks/wh_abc123/deliveries?limit=50" \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Resposta `200 OK`:

```json
{
  "items": [
    {
      "id": 10482,
      "event": "message.received",
      "attempt": 1,
      "statusCode": 200,
      "outcome": "success",
      "errorReason": null,
      "durationMs": 142,
      "createdAt": "2026-06-05T12:00:00Z"
    },
    {
      "id": 10480,
      "event": "message.received",
      "attempt": 2,
      "statusCode": 500,
      "outcome": "failed",
      "errorReason": "HTTP 500",
      "durationMs": 87,
      "createdAt": "2026-06-05T11:59:30Z"
    }
  ]
}
```

Cada item é **uma tentativa** (com o retry, um mesmo evento pode aparecer mais de uma vez):

| Campo         | Significado                                                                               |
| ------------- | ----------------------------------------------------------------------------------------- |
| `event`       | Tipo do evento entregue (ex.: `message.received`).                                        |
| `attempt`     | Número da tentativa (1 = primeira; 2+ = reenvio do retry).                                |
| `statusCode`  | Status HTTP que o seu endpoint respondeu; `null` em falha de rede/timeout (sem resposta). |
| `outcome`     | `success` (`2xx`) ou `failed`.                                                            |
| `errorReason` | Motivo legível quando `failed` (ex.: `HTTP 500`, `erro de rede: ...`); `null` no sucesso. |
| `durationMs`  | Quanto a requisição demorou, em milissegundos.                                            |
| `createdAt`   | Quando a tentativa ocorreu (UTC).                                                         |

Mais recente primeiro; paginado por `?limit` (1–200, padrão 50) e `?offset`. O log é
**retido por 30 dias** e **nunca** inclui o secret nem o corpo da resposta do seu endpoint.

<Callout type="info">
  No **painel**, esse mesmo log fica no botão **Entregas** de cada webhook (Integrações →
  Webhooks). E há um **inspetor de eventos ao vivo** (Integrações → Eventos ao vivo) que mostra os
  eventos do projeto em tempo real, conforme acontecem — ótimo para conferir o que está saindo
  enquanto você desenvolve.
</Callout>

## Idempotência [#idempotência]

A política de retry implica que o **mesmo evento pode ser entregue mais de uma
vez**: um reenvio acontece sempre que a tentativa anterior não respondeu `2xx` a
tempo — inclusive quando o seu endpoint **processou** o evento mas demorou a
responder (e estourou o timeout) ou caiu logo depois. Portanto, o seu endpoint
**deve** tratar as entregas de forma **idempotente**.

<Callout type="warn">
  Não assuma que cada entrega corresponde a um evento único. **Deduplique** pelo identificador de
  **negócio** presente em `data` — por exemplo `waMessageId` em `message.received` ou `waMessageIds`
  em `message.status`. O header `X-Pepino-Webhook-Id` **não** serve para isso: ele é o mesmo em
  todas as entregas do webhook (ver [headers](#headers-de-toda-entrega)).
</Callout>

Recomendações práticas:

* **Registre o que já processou.** Mantenha um índice dos identificadores de
  negócio já tratados e ignore entregas repetidas antes de qualquer efeito.
* **Torne a escrita idempotente.** Use `INSERT ... ON CONFLICT DO NOTHING`
  (ou equivalente) com chave única no identificador do evento, para que
  reprocessar o mesmo evento não duplique linhas nem dispare ações repetidas (um
  e-mail enviado duas vezes, por exemplo).
* **Valide a assinatura antes de processar.** Descartar entregas com assinatura
  inválida evita que um terceiro injete eventos forjados. Ver
  [Assinatura](/docs/webhooks/signing).

## Esqueleto de um receptor correto [#esqueleto-de-um-receptor-correto]

Reunindo tudo — validar a assinatura, deduplicar e responder `2xx` rápido:

```javascript
import crypto from 'node:crypto';

const SECRET = process.env.PEPINO_WEBHOOK_SECRET; // "whsec_..."
const processados = new Set(); // troque por um store persistente (Redis/DB)

function assinaturaOk(rawBody, header) {
  const esperado = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(header || '');
  const b = Buffer.from(esperado);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Capture o RAW body (express.raw), não o JSON já parseado.
app.post('/webhook/pepinozap', express.raw({ type: 'application/json' }), (req, res) => {
  // 1) Valide a assinatura sobre os bytes crus.
  if (!assinaturaOk(req.body, req.get('X-Pepino-Signature'))) {
    return res.status(401).end();
  }

  // 2) Responda 2xx imediatamente — processe de forma assíncrona.
  res.status(200).end();

  // 3) Deduplique por identificador de negócio e processe fora do caminho da resposta.
  const evt = JSON.parse(req.body.toString('utf8'));
  const chave = evt.data?.waMessageId ?? `${evt.event}:${evt.timestamp}`;
  if (processados.has(chave)) return; // entrega repetida — ignore
  processados.add(chave);

  enfileirarParaProcessar(evt); // seu job interno
});
```

## Erros ao cadastrar o webhook [#erros-ao-cadastrar-o-webhook]

Os erros abaixo ocorrem no momento de **criar** o webhook (`POST /v1/webhooks`) —
não na entrega. O envelope de erro é `{ "error": { "code", "message" } }`.

* `400` — corpo inválido: `name` ausente, JSON malformado, `url` que não é uma
  URL `http(s)` absoluta (ou aponta para rede interna — bloqueio anti-SSRF), ou
  um valor de `events` que não é um evento suportado.
* `401` — API key ausente ou inválida, ou que não casa com o host
  (`pzk_live_` em `api.pepinozap.com.br`; `pzk_test_` em
  `sandbox.pepinozap.com.br`).
* `403` — projeto inativo ou sem permissão.
* `404` — (em `DELETE /v1/webhooks/{id}`) webhook não encontrado neste Projeto.

<Callout type="info">
  As entregas seguem o **modo** (teste/produção) em que o webhook foi criado: um webhook criado no
  sandbox só recebe eventos de instâncias de teste, e um webhook de produção só recebe eventos de
  produção. Para receber eventos reais, cadastre o webhook com uma key `pzk_live_` no host de
  produção.
</Callout>

## Próximos passos [#próximos-passos]

* [Assinatura](/docs/webhooks/signing) — validar `X-Pepino-Signature` em Node, Python e Go.
* [Eventos](/docs/webhooks/events) — o envelope e o `data` de cada um dos dez eventos.
* [Webhooks](/docs/webhooks) — cadastrar, listar e remover webhooks via API.
* [WebSocket](/docs/webhooks/websocket) — receber os mesmos eventos sem expor um endpoint público.


# Eventos disponíveis (/docs/webhooks/events)





O PEPINO ZAP envia os eventos abaixo por webhook. Esta página é o **catálogo de eventos**: descreve o envelope comum a todos eles e, para cada evento, o objeto `data` **exato** que você recebe — campo a campo, com exemplos reais de payload.

Você cadastra os webhooks **via API** (`POST /v1/webhooks`), de forma self-service e escopada ao seu Projeto — veja [Webhooks](/docs/webhooks). Para validar que a entrega realmente veio do PEPINO ZAP, consulte [Assinatura](/docs/webhooks/signing). Para o comportamento de timeout, reenvio e idempotência, consulte [Entrega e retry](/docs/webhooks/delivery). Os **mesmos** eventos e o **mesmo** envelope também chegam por [WebSocket](/docs/webhooks/websocket), se você preferir um transporte em tempo real sem expor um endpoint público.

<Callout type="info">
  **Webhook ≠ histórico.** Estes eventos são o **push ao vivo** — o que acontece no momento. Para
  **ler o histórico persistido** (listar conversas, paginar mensagens, baixar a mídia já hospedada
  por nós), use os endpoints do **Inbox** com a **mesma API key** — `GET /v1/inbox/conversations` e
  `.../conversations/{id}/messages`. O PEPINO ZAP é o *system of record*: você **não** precisa
  armazenar as mensagens que chegam aqui por conta própria. Ver [Persistência e
  histórico](/docs/get-started/persistencia).
</Callout>

| Evento               | Quando dispara                                                                                       |
| -------------------- | ---------------------------------------------------------------------------------------------------- |
| `message.received`   | Uma mensagem com conteúdo chegou em uma instância.                                                   |
| `message.status`     | Mudou o status de entrega de uma mensagem **enviada** pela instância (entregue/lida).                |
| `instance.status`    | A conexão da instância com o WhatsApp mudou de estado.                                               |
| `call.received`      | A instância **recebeu uma chamada** (voz ou vídeo).                                                  |
| `message.deleted`    | Um contato &#x2A;*apagou (revogou)** uma mensagem em uma conversa com a instância.                   |
| `group.participants` | Alguém **entrou, saiu, foi promovido ou rebaixado** em um grupo.                                     |
| `group.updated`      | O **nome ou o tópico** (descrição) de um grupo mudou.                                                |
| `contact.updated`    | O **nome de exibição** (push name) de um contato mudou.                                              |
| `label.updated`      | Uma **etiqueta** (WhatsApp Business) foi criada, editada ou apagada.                                 |
| `label.association`  | Uma **etiqueta** foi aplicada ou removida de uma conversa.                                           |
| `campaign.status`    | Mudou o status de um **destinatário de campanha** (enviado/entregue/lido/respondeu/falhou/inválido). |

<Callout type="info">
  Um webhook só recebe os eventos que **assina**. Ao criar o webhook (`POST /v1/webhooks`), o campo
  `events` define a lista — e uma lista &#x2A;*vazia significa "todos os eventos"**. Para receber só
  mensagens recebidas, por exemplo, crie o webhook com `"events": ["message.received"]`. Os nomes
  válidos são exatamente os da tabela acima; qualquer outro valor é recusado com `400`. Ver
  [Webhooks](/docs/webhooks).
</Callout>

## Envelope comum [#envelope-comum]

Toda entrega usa o método `POST` e **sempre** o mesmo envelope. Apenas o conteúdo de `data` muda de um evento para outro — o que permite escrever um único handler que primeiro lê `event` e só então interpreta `data`.

```json
{
  "event": "message.received",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-05-29T22:16:30Z",
  "data": {}
}
```

Campos do envelope:

| Campo        | Tipo   | Significado                                                                                                                                                                                                                                                                                                                                      |
| ------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `event`      | string | Nome do evento — um dos onze da tabela acima (`message.received`, `message.status`, `instance.status`, `call.received`, `message.deleted`, `group.participants`, `group.updated`, `contact.updated`, `label.updated`, `label.association`, `campaign.status`). **Faça o `switch` por aqui** — não tente adivinhar o tipo pelos campos de `data`. |
| `instanceId` | string | Identificador da instância que originou o evento (formato `cm...`). É o mesmo `id` retornado ao criar a instância. Em um Projeto com vários números, use-o para saber **de qual instância** veio o evento.                                                                                                                                       |
| `timestamp`  | string | Data e hora do evento em **ISO 8601 / RFC 3339, em UTC** (sufixo `Z`). Para `message.received` e `message.status`, é o horário do evento no WhatsApp; para `instance.status`, o horário da transição de conexão.                                                                                                                                 |
| `data`       | objeto | Os dados específicos do evento. O formato depende de `event` — detalhado nas seções abaixo.                                                                                                                                                                                                                                                      |

Headers presentes em **toda** entrega:

```http
Content-Type: application/json
User-Agent: pepino-zap-webhooks/1
X-Pepino-Event: message.received
X-Pepino-Webhook-Id: <id-do-webhook>
X-Pepino-Signature: sha256=<hmac-hex>
```

* `X-Pepino-Event` — repete o `event` do corpo. Útil para rotear a entrega **antes** de fazer parse do JSON.
* `X-Pepino-Webhook-Id` — identifica **qual webhook cadastrado** originou a entrega. É **constante** por webhook (o mesmo em todas as entregas), então **não** é uma chave de deduplicação; para deduplicar, use um identificador de negócio em `data` (como `waMessageId`). Ver [Idempotência](/docs/webhooks/delivery#idempotencia).
* `X-Pepino-Signature` — assinatura HMAC-SHA256 do corpo bruto, no formato `sha256=<hmac-hex>`. Permite verificar a autenticidade do que foi recebido. O cálculo está descrito em [Assinatura](/docs/webhooks/signing).

<Callout type="warn">
  Calcule e valide a assinatura sobre os **bytes crus** do corpo, antes de qualquer `JSON.parse`.
  Re-serializar o objeto depois do parse muda espaçamento e ordem de chaves, e a assinatura deixa de
  bater. Ver [Assinatura](/docs/webhooks/signing).
</Callout>

## `message.received` [#messagereceived]

Disparado quando a instância **recebe** uma mensagem com conteúdo.

```json
{
  "event": "message.received",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-05-29T22:16:30Z",
  "data": {
    "from": "5511999999999@s.whatsapp.net",
    "type": "image",
    "text": "Mensagem de teste",
    "waMessageId": "3EB0C767D26A1D8E2B5F",
    "mediaUrl": "https://...&X-Amz-Expires=86400",
    "mimeType": "image/jpeg"
  }
}
```

Campos de `data`:

| Campo         | Tipo   | Sempre presente?       | Significado                                                                                                                                                                                                                                                       |
| ------------- | ------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `from`        | string | Sim                    | Remetente da mensagem (ou o grupo de origem). Pode vir em mais de um formato — ver abaixo.                                                                                                                                                                        |
| `type`        | string | Sim                    | Tipo do conteúdo: `text`, `image`, `video`, `audio`, `document`, `sticker`, `location`, `contact`, `poll` ou `unknown`.                                                                                                                                           |
| `text`        | string | Sim (pode ser `""`)    | Conteúdo textual. Em `text`, é o corpo. Em `image`/`video`/`document`, é a **legenda** (vazio se não houver). Em `location`/`contact`/`poll`, é o **nome/título** do item (nome do local, nome do contato, pergunta da enquete). Em `audio`/`sticker`, vem vazio. |
| `waMessageId` | string | Sim                    | Identificador da mensagem no WhatsApp. Use-o para correlacionar com replies e para **deduplicar** mensagens repetidas.                                                                                                                                            |
| `mediaUrl`    | string | Só em mídia baixada    | URL pré-assinada para baixar o arquivo. Presente **apenas** quando o tipo é de mídia (`image`/`video`/`audio`/`document`/`sticker`) **e** o download foi concluído. Ver o Callout abaixo.                                                                         |
| `mimeType`    | string | Só junto de `mediaUrl` | Tipo MIME do arquivo (ex. `image/jpeg`, `audio/ogg`, `application/pdf`). Acompanha sempre o `mediaUrl`.                                                                                                                                                           |

<Callout type="warn">
  O objeto `data` traz **apenas** estes campos. Não há `pushName`, `senderPhone`, `conversationId`
  nem dados da mensagem citada no payload do webhook — não dependa de campos que não estão listados
  aqui.
</Callout>

### O campo `type` em detalhe [#o-campo-type-em-detalhe]

O `type` indica como interpretar o restante do payload:

| `type`     | O que é                                                                    | O que esperar em `text`     | `mediaUrl`?         |
| ---------- | -------------------------------------------------------------------------- | --------------------------- | ------------------- |
| `text`     | Mensagem de texto (inclui texto com link/citação)                          | O corpo da mensagem         | Não                 |
| `image`    | Imagem                                                                     | A legenda (se houver)       | Sim, quando baixada |
| `video`    | Vídeo                                                                      | A legenda (se houver)       | Sim, quando baixada |
| `audio`    | Áudio ou nota de voz                                                       | Vazio                       | Sim, quando baixada |
| `document` | Documento/arquivo                                                          | A legenda (se houver)       | Sim, quando baixada |
| `sticker`  | Figurinha                                                                  | Vazio                       | Sim, quando baixada |
| `location` | Localização (pin ou ao vivo)                                               | O nome do local (se houver) | Não                 |
| `contact`  | Contato compartilhado (vCard)                                              | O nome exibido do contato   | Não                 |
| `poll`     | Enquete                                                                    | A pergunta da enquete       | Não                 |
| `unknown`  | Conteúdo recebido que não se encaixa nos tipos acima, mas ainda traz texto | Pode ter texto              | Não                 |

<Callout type="info">
  Trate `type` como uma lista **aberta**: faça um `switch` nos valores que você conhece e tenha um
  caso `default` para o resto. Assim, qualquer tipo novo (ou `unknown`) é tratado de forma graciosa,
  sem quebrar o handler.
</Callout>

### O campo `from`: dois (e às vezes três) formatos [#o-campo-from-dois-e-às-vezes-três-formatos]

O `from` identifica a origem e **não é sempre um telefone**. Ele pode vir em três formatos, distinguíveis pelo sufixo após o `@`:

* **Número** com sufixo `@s.whatsapp.net` — por exemplo `5511999999999@s.whatsapp.net`. É o caso mais comum: o trecho antes do `@` é o telefone em E.164 (sem o `+`).
* **LID** com sufixo `@lid` — por exemplo `123456789@lid`. Ocorre quando o contato usa o recurso de privacidade do WhatsApp e o número real ainda não foi resolvido. Aqui o valor antes do `@lid` é um **identificador interno**, não um telefone.
* **Grupo** com sufixo `@g.us` — por exemplo `120363012345678901@g.us`. Aparece quando a mensagem foi recebida em um **grupo**. O valor antes do `@` identifica o grupo, não uma pessoa.

<Callout type="warn">
  Ao processar `from`, **não** assuma que o trecho antes do `@` é sempre um telefone. Use o
  **sufixo** para decidir: `@s.whatsapp.net` = número, `@lid` = identificador de privacidade,
  `@g.us` = grupo. Tentar discar/normalizar o trecho de um `@lid` ou `@g.us` como telefone leva a
  dados incorretos.
</Callout>

<Callout type="info">
  Mensagens recebidas em **grupos** geram `message.received` por padrão (com `from` terminando em
  `@g.us`). Se você só atende conversas 1:1, ligue a configuração `ignoreGroups` da instância — com
  ela ativa, mensagens de grupo não geram webhook. Ver [Configurações da
  instância](/docs/get-started/first-instance#configuracoes-da-instancia).
</Callout>

### Mídia: como baixar o arquivo [#mídia-como-baixar-o-arquivo]

Em mensagens de mídia, o binário não vem no payload — vem uma **URL pré-assinada** em `mediaUrl`, com validade de **24 horas**.

```json
"data": {
  "from": "5511999999999@s.whatsapp.net",
  "type": "audio",
  "text": "",
  "waMessageId": "3EB0C767D26A1D8E2B5F",
  "mediaUrl": "https://....s3.amazonaws.com/media/...&X-Amz-Expires=86400",
  "mimeType": "audio/ogg"
}
```

Baixe o arquivo diretamente pela URL, com um `GET` simples e **sem credenciais adicionais** (a autorização já está embutida na própria URL):

```bash
curl -L -o arquivo "$MEDIA_URL"
```

<Callout type="info">
  `mediaUrl` só é incluído quando o download da mídia foi **concluído** pela plataforma. Se o
  download não estava pronto no momento do evento, o payload vem **sem** `mediaUrl`/`mimeType`,
  apenas com os metadados (`from`, `type`, `text`, `waMessageId`). Trate a ausência desses campos
  com naturalidade — não é um erro. Como a URL expira em 24h, **baixe e armazene** o arquivo no seu
  lado se precisar dele por mais tempo; não guarde a URL para usar depois.
</Callout>

### O que **não** gera `message.received` [#o-que-não-gera-messagereceived]

Apenas mensagens com **conteúdo exibível** geram o evento. Não são entregues:

* **Ecos das próprias mensagens** que a instância envia (mensagens "de mim").
* **Mensagens de controle** do WhatsApp: cabeçalho de álbum (vem junto das fotos), reações, edições, exclusões, distribuição de chave de sessão e mensagens de protocolo.
* **Status** (`status@broadcast`), **listas de transmissão** e **canais** (newsletters) — não são conversas e não viram evento.
* **Grupos**, quando a configuração `ignoreGroups` da instância está ligada.

## `message.status` [#messagestatus]

Disparado quando muda o status de entrega de uma mensagem que **a instância enviou**. Como o envio é assíncrono (a API responde `202` apenas enfileirando a mensagem), este evento é a sua **confirmação de entrega e de leitura**.

```json
{
  "event": "message.status",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-05-29T22:17:00Z",
  "data": {
    "status": "READ",
    "to": "5511999999999@s.whatsapp.net",
    "waMessageIds": ["3EB0C767D26A1D8E2B5F", "3EB1A2B3C4D5E6F70819"]
  }
}
```

Campos de `data`:

| Campo          | Tipo            | Significado                                                                                                                                  |
| -------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`       | string          | `DELIVERED` (entregue no aparelho do destinatário) ou `READ` (lida — ou, no caso de áudio, ouvida).                                          |
| `to`           | string          | Destinatário das mensagens, no mesmo formato de JID do `from` (geralmente `...@s.whatsapp.net`; pode ser `...@g.us` para grupos).            |
| `waMessageIds` | array de string | Lista dos `waMessageId` afetados por este status. **Um único receipt pode cobrir várias mensagens de uma vez** — por isso é sempre um array. |

Para correlacionar com o que você enviou, guarde o `waMessageId` retornado no envio e cruze-o com os `waMessageIds` recebidos aqui.

<Callout type="info">
  O fluxo normal é `DELIVERED` seguido de `READ`, mas **nem toda mensagem recebe os dois** (e nem
  necessariamente nessa ordem): se o destinatário nunca abre a conversa, você verá só `DELIVERED`;
  com retries, o mesmo `status` pode chegar repetido. Trate cada `message.status` como uma
  atualização **idempotente** do estado da mensagem, e não como um passo obrigatório de uma
  sequência. Não há um status de "falhou" neste evento.
</Callout>

## `instance.status` [#instancestatus]

Disparado quando muda o status de **conexão** da instância com o WhatsApp.

```json
{
  "event": "instance.status",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-05-29T22:18:00Z",
  "data": {
    "status": "DISCONNECTED",
    "jid": "",
    "reason": ""
  }
}
```

Campos de `data`:

| Campo    | Tipo   | Significado                                                                                                                                                                                  |
| -------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status` | string | A transição de conexão. Assume um dos valores da tabela abaixo.                                                                                                                              |
| `jid`    | string | Identificador WhatsApp do número conectado (ex. `5511999999999@s.whatsapp.net`). Preenchido quando há um número associado à transição (notadamente em `CONNECTED`); vem `""` caso contrário. |
| `reason` | string | Informação adicional sobre a transição, quando houver (por exemplo, o motivo de um `LOGGED_OUT` ou o código de um `BANNED`). Vem `""` quando não há detalhe.                                 |

Valores possíveis de `status`:

| `status`          | Significado                                                                                     | O que fazer                                                                                                                   |
| ----------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `CONNECTED`       | A instância conectou e está operacional. O campo `jid` traz o identificador WhatsApp do número. | Pode enviar mensagens.                                                                                                        |
| `DISCONNECTED`    | A conexão caiu.                                                                                 | Nada — a instância **reconecta automaticamente**.                                                                             |
| `LOGGED_OUT`      | A sessão foi encerrada no aparelho (o usuário removeu o aparelho conectado).                    | É preciso **parear de novo via QR Code**.                                                                                     |
| `STREAM_REPLACED` | O número foi aberto em outra sessão; esta conexão foi substituída.                              | Verifique se o número não está conectado em outro lugar.                                                                      |
| `BANNED`          | O número foi bloqueado pela plataforma do WhatsApp (temporário ou permanente).                  | Estado terminal — não reconecta; o envio passa a responder `409`. O `reason` traz o código do bloqueio. Ver o detalhe abaixo. |

<Callout type="info">
  Este evento cobre apenas as **transições** acima. O ciclo de **pareamento** — os estados
  `CONNECTING` e `QR_PENDING`, e o sucesso de leitura do QR — **não** é enviado por
  `instance.status`. Acompanhe o pareamento pelo [stream de
  QR](/docs/api/instancias/streamInstanceQR) (ou pelo polling) e consulte o estado atual a qualquer
  momento com [`GET /v1/instances/{id}`](/docs/api/instancias/getInstance).
</Callout>

<Callout type="warn">
  Uma vez pareada, a instância **reconecta sozinha** após quedas, reinícios e deploys, sem novo QR.
  Por isso, um `DISCONNECTED` é normalmente transitório: não acione um novo pareamento por causa
  dele. Só `LOGGED_OUT` exige escanear o QR de novo. &#x2A;*Você pode confiar no `status`:** se a sessão
  morre — por exemplo, o número é **desvinculado no aparelho** enquanto a instância está fora do ar e
  o `LOGGED_OUT` não chega na hora — a plataforma **reconcilia** o estado e emite um `instance.status`
  com `DISCONNECTED`, em geral em um a dois minutos. Ou seja, o `status` **não fica preso
  indefinidamente** em um `CONNECTED` fantasma. Se, mesmo assim, uma instância permanecer em
  `DISCONNECTED` sem voltar para `CONNECTED` por alguns minutos (confirme com [`GET
    /v1/instances/{id}`](/docs/api/instancias/getInstance)), trate como sessão encerrada e gere um novo
  QR Code. Para o ciclo de vida completo, veja [Sua primeira
  instância](/docs/get-started/first-instance#ciclo-de-vida).
</Callout>

<Callout type="info">
  **Sobre o `BANNED`.** Ele cobre dois casos que o WhatsApp trata como bloqueio: um **bloqueio
  temporário** (anti-spam — por exemplo, mensagens demais para quem não tem você na agenda, ou gente
  demais te bloqueando) e um **banimento permanente** da conta. Nos dois o `status` vai para
  `BANNED`, o envio passa a responder `409` e a instância **não reconecta**; o `reason` traz o código
  do bloqueio quando o WhatsApp o informa. &#x2A;*Há ainda um caso sem evento:** um número "sombreado"
  (soft-block) pode, em vez de emitir `BANNED`, simplesmente **nunca concluir o pareamento** — o QR
  fica sendo regerado sem chegar a `CONNECTED`. O protocolo multi-device do WhatsApp não dá um sinal
  determinístico de banimento aí; por isso, trate um número que **não pareia depois de várias
  tentativas** como provavelmente bloqueado, em vez de insistir no mesmo número.
</Callout>

## `call.received` [#callreceived]

Disparado quando a instância **recebe uma chamada** (voz ou vídeo) de um contato — no mesmo instante em que o telefone "tocaria". Útil para registrar tentativas de ligação, alertar um atendente, ou casar com a configuração de **rejeitar chamadas**: mesmo com a rejeição ligada, o `call.received` é emitido — a chamada chegou, e só depois foi rejeitada.

```json
{
  "event": "call.received",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "from": "5511999999999@s.whatsapp.net",
    "callId": "AB12CD34EF56"
  }
}
```

| Campo    | Tipo   | Descrição                                                                                                      |
| -------- | ------ | -------------------------------------------------------------------------------------------------------------- |
| `from`   | string | Quem ligou, no mesmo formato JID do `from` de `message.received` (a parte antes do `@` é o telefone em E.164). |
| `callId` | string | Identificador da chamada no WhatsApp. Útil para correlacionar os eventos da mesma ligação e para deduplicar.   |

<Callout type="info">
  Este evento sinaliza apenas que **chegou** uma chamada — não há eventos de "atendida", "encerrada"
  ou duração. Se você quer que o número **nunca** receba chamadas, ligue `rejectCalls` (e
  opcionalmente `rejectCallMessage`) nas [configurações da
  instância](/docs/get-started/first-instance#configuracoes-da-instancia).
</Callout>

## `message.deleted` [#messagedeleted]

Disparado quando um contato &#x2A;*apaga para todos (revoga)** uma mensagem em uma conversa com a instância — o "Apagar mensagem → Apagar para todos" do WhatsApp. Serve para manter o seu CRM/registro **fiel**: ao receber este evento, marque como apagada (ou esconda) a mensagem correspondente no seu sistema.

```json
{
  "event": "message.deleted",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "from": "5511999999999@s.whatsapp.net",
    "waMessageId": "3EB0C767D26A1D8E2B5F"
  }
}
```

| Campo         | Tipo   | Descrição                                                                                                                                            |
| ------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `from`        | string | A conversa onde a mensagem foi apagada (JID do contato ou do grupo), no mesmo formato do `from` de `message.received`.                               |
| `waMessageId` | string | O `waMessageId` da **mensagem apagada** — cruze com o `waMessageId` recebido em `message.received` para localizar qual mensagem revogar no seu lado. |

<Callout type="info">
  Refere-se a apagar **para todos** (revogação), que é o que se propaga pelo WhatsApp — "Apagar para
  mim" é local no aparelho de quem apagou e **não** gera evento. Respeita o `ignoreGroups`: se você
  ignora grupos, uma revogação em grupo também não dispara.
</Callout>

## `group.participants` [#groupparticipants]

Disparado quando a composição de um grupo muda: alguém **entrou**, **saiu** (ou foi removido), foi **promovido** a admin ou **rebaixado**. Só vale para grupos dos quais a instância participa.

```json
{
  "event": "group.participants",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "groupJid": "120363012345678901@g.us",
    "by": "5511999999999@s.whatsapp.net",
    "joined": ["5511888888888@s.whatsapp.net"],
    "left": [],
    "promoted": [],
    "demoted": []
  }
}
```

| Campo                                      | Tipo            | Descrição                                                                                 |
| ------------------------------------------ | --------------- | ----------------------------------------------------------------------------------------- |
| `groupJid`                                 | string          | O grupo (`...@g.us`).                                                                     |
| `by`                                       | string          | Quem fez a mudança (`""` se o WhatsApp não informou).                                     |
| `joined` / `left` / `promoted` / `demoted` | array de string | Os participantes afetados (JIDs). Em geral só um desses arrays vem preenchido por evento. |

## `group.updated` [#groupupdated]

Disparado quando os **metadados** de um grupo mudam — o **nome** ou o **tópico** (descrição).

```json
{
  "event": "group.updated",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "groupJid": "120363012345678901@g.us",
    "by": "5511999999999@s.whatsapp.net",
    "name": "Novo nome do grupo"
  }
}
```

| Campo      | Tipo   | Descrição                                                   |
| ---------- | ------ | ----------------------------------------------------------- |
| `groupJid` | string | O grupo.                                                    |
| `by`       | string | Quem fez a mudança.                                         |
| `name`     | string | Presente **só** se o nome mudou — o novo nome.              |
| `topic`    | string | Presente **só** se o tópico/descrição mudou — o novo texto. |

## `contact.updated` [#contactupdated]

Disparado quando o **nome de exibição** (push name) de um contato muda — percebido ao receber uma mensagem dele com um nome diferente do que estava em cache.

```json
{
  "event": "contact.updated",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "from": "5511999999999@s.whatsapp.net",
    "oldName": "João",
    "newName": "João Silva"
  }
}
```

| Campo     | Tipo   | Descrição                   |
| --------- | ------ | --------------------------- |
| `from`    | string | O contato.                  |
| `oldName` | string | O nome anterior (em cache). |
| `newName` | string | O novo nome de exibição.    |

<Callout type="info">
  Refere-se ao **push name** que o próprio contato definiu — não à foto de perfil, e não ao nome que
  **você** salvou na sua agenda.
</Callout>

## `label.updated` [#labelupdated]

Disparado quando uma **etiqueta** (recurso do WhatsApp **Business**) é criada, renomeada ou apagada.

```json
{
  "event": "label.updated",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "labelId": "5",
    "name": "Cliente VIP",
    "color": 3,
    "deleted": false
  }
}
```

| Campo     | Tipo    | Descrição                              |
| --------- | ------- | -------------------------------------- |
| `labelId` | string  | Identificador da etiqueta.             |
| `name`    | string  | Nome atual da etiqueta.                |
| `color`   | number  | Índice de cor da etiqueta no WhatsApp. |
| `deleted` | boolean | `true` se a etiqueta foi apagada.      |

## `label.association` [#labelassociation]

Disparado quando uma **etiqueta** é **aplicada** ou **removida** de uma conversa.

```json
{
  "event": "label.association",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "labelId": "5",
    "chat": "5511999999999@s.whatsapp.net",
    "labeled": true
  }
}
```

| Campo     | Tipo    | Descrição                              |
| --------- | ------- | -------------------------------------- |
| `labelId` | string  | A etiqueta.                            |
| `chat`    | string  | A conversa (contato ou grupo).         |
| `labeled` | boolean | `true` = aplicada; `false` = removida. |

<Callout type="info">
  Etiquetas (`label.*`) são um recurso do **WhatsApp Business** — só fazem sentido se o número
  conectado é uma conta Business que usa etiquetas. Os eventos do **sync inicial** são ignorados;
  você recebe só as mudanças em tempo real.
</Callout>

## `campaign.status` [#campaignstatus]

Disparado a cada mudança de status de um **destinatário de uma campanha** de disparo em massa. É o acompanhamento em tempo real do disparo, por destinatário — complementar ao agregado de `GET /v1/campaigns/{id}`.

```json
{
  "event": "campaign.status",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-06-18T14:32:00Z",
  "data": {
    "campaignId": "camp_3Hk9Zq2pVxL",
    "recipient": "5511999999999",
    "name": "Maria",
    "status": "delivered",
    "error": null
  }
}
```

Campos de `data`:

| Campo        | Tipo           | Significado                                                              |
| ------------ | -------------- | ------------------------------------------------------------------------ |
| `campaignId` | string         | A campanha a que o destinatário pertence.                                |
| `recipient`  | string         | O número (apenas dígitos) do destinatário.                               |
| `name`       | string         | Nome do destinatário (variável `nome`), se informado. Pode vir ausente.  |
| `status`     | string         | Estado: `sent`, `delivered`, `read`, `responded`, `failed` ou `invalid`. |
| `error`      | string \| null | Motivo, quando `failed` ou `invalid` (ex.: número não está no WhatsApp). |

O ciclo típico de um destinatário é `sent` -> `delivered` -> `read`, e `responded` se o contato responder. `invalid` sai antes do envio (número sem WhatsApp, quando `validateNumbers` está ligado) e **não consome** o teto diário; `failed` é uma falha real de envio. Trate cada evento como uma atualização **idempotente** do estado daquele destinatário — nem todos os estados ocorrem para todos, e a ordem pode variar.

## Erros e situações de borda [#erros-e-situações-de-borda]

Estes eventos são **entregues ao seu endpoint**; eles não retornam códigos de erro para você. O que você precisa observar do lado do receptor:

* **Responda `2xx` rápido.** Qualquer resposta fora de `2xx`, timeout (15s) ou erro de rede faz a entrega **falhar e ser reenviada** com backoff, até 6 tentativas. Ver [Entrega e retry](/docs/webhooks/delivery).
* **Deduplique.** Por causa do retry, o **mesmo evento pode chegar mais de uma vez**. Use um identificador de negócio em `data` — `waMessageId` em `message.received`, `waMessageIds` em `message.status` — como chave de idempotência. O `X-Pepino-Webhook-Id` é constante por webhook e **não** serve para isso.
* **Valide a assinatura.** Trate como inválida (e descarte) qualquer entrega cujo `X-Pepino-Signature` não bata com o HMAC do corpo bruto. Ver [Assinatura](/docs/webhooks/signing).
* **Tolere campos ausentes e tipos novos.** Em `message.received`, `mediaUrl`/`mimeType` podem não vir; `text` pode ser `""`. Em `instance.status`, `jid`/`reason` podem ser `""`. Trate `type` e `status` como listas abertas, com um caso `default`.

Os erros de **cadastro** de webhook (criar/listar/remover via `POST /v1/webhooks`) — como `400` para evento inválido ou URL recusada, `401` para key ausente/inválida — estão documentados em [Webhooks](/docs/webhooks).

## Próximos passos [#próximos-passos]

* [Webhooks](/docs/webhooks) — como cadastrar webhooks via API e gerenciar o secret.
* [Assinatura](/docs/webhooks/signing) — como validar `X-Pepino-Signature` (com exemplos em Node, Python e Go).
* [Entrega e retry](/docs/webhooks/delivery) — timeout, critério de sucesso e política de reenvio.
* [WebSocket](/docs/webhooks/websocket) — receber os mesmos eventos em tempo real, sem endpoint público.


# Webhooks (/docs/webhooks)





Um webhook é uma notificação que o PEPINO ZAP envia ao **seu** servidor quando algo acontece em uma instância — uma mensagem chega, o status de entrega de uma mensagem enviada muda, ou a conexão da instância muda de estado. Em vez de o seu sistema ficar consultando a API repetidamente para saber "chegou algo novo?", o seu endpoint recebe um `POST` assinado a cada evento, no momento em que ele acontece.

<Callout type="warn">
  **A URL do webhook é a *sua* — o PEPINO ZAP não gera nenhuma URL.** Aqui o modelo é o de
  Stripe/Twilio, não o de Slack/Discord. No Slack/Discord você cria um "incoming webhook" e *recebe*
  uma URL pronta para postar; **aqui é o inverso**: **você** expõe um endpoint público no **seu**
  servidor (ex.: `https://seusistema.com/webhook/pepinozap`) e cadastra a URL dele no campo `url`; o
  PEPINO ZAP faz `POST` nesse endereço a cada evento. **Não existe nenhuma URL gerada por nós para
  você consumir** — quem fornece a URL é você.
</Callout>

Esta página é a **visão geral**: o que é um webhook, como cadastrá-lo via API (self-service), o que cada chamada retorna, os erros possíveis e quando preferir webhook ou WebSocket. O detalhe de cada parte vive nas páginas de [Eventos](/docs/webhooks/events), [Assinatura](/docs/webhooks/signing) e [Entrega e retry](/docs/webhooks/delivery).

Todas as requisições de gerenciamento (cadastrar, listar, remover) exigem o header `Authorization: Bearer <API_KEY>`. Os exemplos usam a URL de produção `https://api.pepinozap.com.br` com uma key `pzk_live_`; no modo teste, use `https://sandbox.pepinozap.com.br` com uma key `pzk_test_`. O **host** decide o modo, e a key precisa casar com o host — `pzk_live_` em `api.*`, `pzk_test_` em `sandbox.*`; usar a key no host errado retorna `401`. Ver [Autenticação](/docs/get-started/authentication#modos-teste-e-producao).

## Como funciona, em três passos [#como-funciona-em-três-passos]

1. Uma instância gera um evento — `message.received` (mensagem recebida), `message.status` (entregue/lida) ou `instance.status` (conexão mudou).
2. A plataforma envia um `POST` assinado para a URL que você cadastrou, com o corpo do evento e os headers de identificação e assinatura.
3. O seu backend lê o corpo, valida a assinatura e responde com `2xx` para confirmar o recebimento. Se você não responder `2xx` (ou demorar mais que o timeout), a entrega é reenviada.

Cada entrega traz três headers, sempre:

| Header                | Conteúdo                                                                                                                                                                                                                                                                                                                               |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-Pepino-Event`      | Nome do evento da entrega (ex.: `message.received`).                                                                                                                                                                                                                                                                                   |
| `X-Pepino-Webhook-Id` | Identificador do **webhook cadastrado** (a configuração, ex.: `wh_abc123`). É **constante** em todas as entregas daquele mesmo webhook — não é um id por entrega nem um número de sequência. Para **deduplicar** (o mesmo evento pode chegar mais de uma vez), use um identificador de negócio presente em `data`, como `waMessageId`. |
| `X-Pepino-Signature`  | `sha256=<hmac-hex>` — o HMAC-SHA256 do corpo **bruto** da requisição, calculado com o secret do webhook.                                                                                                                                                                                                                               |

A assinatura é o que prova que a entrega veio do PEPINO ZAP (e não foi adulterada). Validá-la é **fortemente recomendado** antes de processar qualquer entrega — veja [Assinatura](/docs/webhooks/signing).

## O envelope de cada entrega [#o-envelope-de-cada-entrega]

Independentemente do evento, o corpo do `POST` tem sempre o mesmo formato de envelope. O que muda entre os eventos é só o conteúdo de `data`:

```json
{
  "event": "message.received",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-05-30T12:00:00Z",
  "data": {}
}
```

* `event` — o nome do evento (`message.received`, `message.status` ou `instance.status`).
* `instanceId` — a instância que originou o evento. Um webhook é escopado ao **Projeto**, então recebe eventos de **todas** as instâncias do Projeto; use este campo para saber de qual.
* `timestamp` — data e hora do evento em ISO 8601 (UTC).
* `data` — o objeto específico do evento. O formato de cada um está em [Eventos](/docs/webhooks/events).

## Cadastrando um webhook [#cadastrando-um-webhook]

Você cadastra seus webhooks **via API**, de forma **self-service**, escopados ao seu Projeto — não depende do operador. Faça um `POST /v1/webhooks` com três campos:

```bash
curl -X POST https://api.pepinozap.com.br/v1/webhooks \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "meu-endpoint",
    "url": "https://meusistema.com/webhook/pepinozap",
    "events": []
  }'
```

### O corpo da requisição [#o-corpo-da-requisição]

| Campo    | Tipo             | Obrigatório | O que é                                                                                                                                                                                     |
| -------- | ---------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name`   | string           | Sim         | Um rótulo livre para você identificar o webhook (ex.: `producao-crm`). Espaços nas pontas são removidos; vazio é rejeitado com `400`. Nomes podem repetir — não há checagem de duplicidade. |
| `url`    | string           | Sim         | A URL pública do seu endpoint que vai receber os `POST`. Precisa ser `http`/`https` absoluta. Recomenda-se `https`.                                                                         |
| `events` | array de strings | Não         | Quais eventos este webhook deve receber. &#x2A;*Array vazio (`[]`) ou omitido = todos os eventos.** Para filtrar, liste só os que quer (ex.: `["message.received"]`).                       |

Os valores aceitos em `events` são estes:

| Valor                | Quando dispara                                                           |
| -------------------- | ------------------------------------------------------------------------ |
| `message.received`   | Uma mensagem chegou em uma instância.                                    |
| `message.status`     | Mudou o status de entrega de uma mensagem enviada (entregue/lida).       |
| `instance.status`    | A conexão de uma instância mudou (conectou, caiu, deslogou, foi banida). |
| `call.received`      | Uma chamada (voz/vídeo) foi recebida.                                    |
| `message.deleted`    | O remetente apagou/revogou uma mensagem.                                 |
| `group.participants` | Alguém entrou/saiu ou foi promovido/rebaixado em um grupo.               |
| `group.updated`      | Nome ou descrição de um grupo mudou.                                     |
| `contact.updated`    | O nome de exibição de um contato mudou.                                  |
| `label.updated`      | Uma etiqueta (Business) foi criada, editada ou apagada.                  |
| `label.association`  | Uma etiqueta foi aplicada ou removida de uma conversa.                   |

Qualquer string fora dessa lista faz a chamada falhar com `400` e a mensagem `evento inválido: <valor>`. O payload dos principais está em [Eventos](/docs/webhooks/events).

<Callout type="warn">
  A `url` é validada na criação: precisa ter esquema `http` ou `https` e ter um host. Se você
  apontar para um **endereço IP interno/privado** literal (loopback como `127.0.0.1`, faixas
  privadas como `10.x`/`192.168.x`, link-local etc.), a criação é rejeitada com `400` — é uma
  proteção anti-SSRF. Para testar localmente, exponha o endpoint com um túnel (por exemplo, um
  serviço de tunelamento HTTP) e cadastre a URL pública dele, não `localhost`.
</Callout>

### A resposta: `201 Created` com o secret [#a-resposta-201-created-com-o-secret]

A criação retorna `201 Created` com o webhook recém-criado **e** o `secret`:

```json
{
  "id": "wh_abc123",
  "name": "meu-endpoint",
  "url": "https://meusistema.com/webhook/pepinozap",
  "events": [],
  "enabled": true,
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "lastFailureReason": null,
  "createdAt": "2026-05-30T12:00:00Z",
  "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```

* `id` — o identificador do webhook. Guarde-o: é o que você usa para **remover** o webhook depois (`DELETE /v1/webhooks/{id}`).
* `enabled` — webhooks nascem **habilitados** (`true`); já começam a receber entregas. Você pode **pausar** e **retomar** a entrega depois sem perder a config (ver [Pausar e retomar](#pausar-e-retomar)).
* `lastSuccessAt`, `lastFailureAt`, `lastFailureReason` — telemetria da última entrega bem-sucedida e da última falha. Vêm `null` enquanto não houve entrega; depois ajudam a diagnosticar um endpoint com problema (veja [Entrega e retry](/docs/webhooks/delivery)).
* `secret` — a chave para validar a assinatura das entregas. Detalhado a seguir.

### O secret `whsec_` é mostrado uma única vez [#o-secret-whsec_-é-mostrado-uma-única-vez]

O campo `secret`, com prefixo `whsec_`, **só aparece nesta resposta de criação**. Ele não volta a ser exibido em nenhuma outra chamada — `GET /v1/webhooks` **não** o retorna. É com esse valor que você recalcula o HMAC e confere o header `X-Pepino-Signature` de cada entrega (ver [Assinatura](/docs/webhooks/signing)).

<Callout type="warn">
  Copie o `secret` agora e guarde-o em variável de ambiente ou cofre de segredos (fora do
  código-fonte). Se você perder o secret, **não há como recuperá-lo**: a única saída é **remover o
  webhook e criar outro**, o que gera um secret novo — e, a partir daí, você passa a validar as
  assinaturas com o secret novo. Cada webhook tem o seu próprio secret.
</Callout>

### Variações de cadastro [#variações-de-cadastro]

**Recebendo só um tipo de evento.** Liste apenas o que interessa em `events`:

```bash
curl -X POST https://api.pepinozap.com.br/v1/webhooks \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "so-recebidas", "url": "https://meusistema.com/in", "events": ["message.received"] }'
```

**Vários endpoints.** Você pode cadastrar mais de um webhook no mesmo Projeto — por exemplo, um endpoint para mensagens e outro para status de conexão. Cada um tem URL, eventos e secret próprios, e cada um recebe sua própria entrega assinada.

## Listando seus webhooks [#listando-seus-webhooks]

Use `GET /v1/webhooks` para ver os webhooks cadastrados no Projeto:

```bash
curl https://api.pepinozap.com.br/v1/webhooks \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

A resposta é `200 OK` com um array em `items`:

```json
{
  "items": [
    {
      "id": "wh_abc123",
      "name": "meu-endpoint",
      "url": "https://meusistema.com/webhook/pepinozap",
      "events": [],
      "enabled": true,
      "lastSuccessAt": "2026-06-05T11:59:00Z",
      "lastFailureAt": null,
      "lastFailureReason": null,
      "createdAt": "2026-05-30T12:00:00Z"
    }
  ]
}
```

<Callout type="info">
  Repare que a listagem &#x2A;*não inclui o `secret`** — esse campo só existe na resposta de criação. Use
  `lastSuccessAt` / `lastFailureAt` / `lastFailureReason` para auditar a saúde de cada endpoint: um
  `lastFailureReason` preenchido e um `lastSuccessAt` antigo indicam que o seu endpoint vem
  rejeitando ou não respondendo às entregas.
</Callout>

A listagem respeita o **modo da key**: uma key `pzk_test_` (host `sandbox.*`) lista os webhooks de teste; uma key `pzk_live_` (host `api.*`) lista os de produção. São conjuntos separados.

## Removendo um webhook [#removendo-um-webhook]

Para parar de receber entregas em um endpoint, remova o webhook pelo `id` retornado na criação (ou obtido na listagem):

```bash
curl -X DELETE https://api.pepinozap.com.br/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer $YOUR_API_KEY"
```

Em sucesso, retorna `204 No Content` (sem corpo). Se o `id` não existir, não for do seu Projeto, ou for de outro modo (teste vs. produção), retorna `404` com `webhook não encontrado neste projeto`.

A remoção é **definitiva** e invalida o secret daquele webhook. Para apenas **interromper temporariamente** as entregas (sem perder a config), use o **pause** abaixo em vez de remover. Já para trocar **URL ou eventos** não há edição: o fluxo é **remover e criar de novo** com os novos parâmetros (o que gera um secret novo) — atualize a configuração do seu endpoint antes de descartar o antigo.

## Pausar e retomar [#pausar-e-retomar]

Às vezes você precisa **parar de receber** as entregas por um tempo — manutenção do seu endpoint, um deploy, ou investigar um problema — sem **perder a configuração** (URL, secret e eventos). Para isso, alterne o campo `enabled` com `PATCH /v1/webhooks/{id}`:

```bash
# pausar a entrega
curl -X PATCH https://api.pepinozap.com.br/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'

# retomar
curl -X PATCH https://api.pepinozap.com.br/v1/webhooks/wh_abc123 \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'
```

Retorna `200 OK` com o webhook atualizado. Enquanto `enabled` for `false`, **nenhuma entrega é feita** para aquele endpoint (os eventos nem chegam a ser enfileirados para ele); ao voltar para `true`, as entregas recomeçam a partir dos **próximos** eventos — o que ocorreu durante a pausa **não** é entregue retroativamente.

<Callout type="info">
  **A vantagem sobre remover:** pausar mantém o &#x2A;*mesmo `id` e o mesmo `secret`** — você religa
  quando quiser, sem reconfigurar nada do seu lado nem trocar a chave de assinatura. Remover, ao
  contrário, é definitivo e invalida o secret. No painel (**Integrações → Webhooks**), esse mesmo
  controle é o botão **Pausar / Retomar**.
</Callout>

## Entrega e idempotência (resumo) [#entrega-e-idempotência-resumo]

A plataforma envia cada entrega via `POST` e espera uma resposta `2xx` rápida. O comportamento completo está em [Entrega e retry](/docs/webhooks/delivery); o essencial:

* **Timeout de 15 s por tentativa.** Responda `2xx` em poucos segundos e processe o evento de forma assíncrona.
* **Sucesso = `2xx`.** Qualquer `4xx`/`5xx`, timeout ou erro de rede conta como falha.
* **Retry com backoff exponencial, até 6 tentativas.** Por isso, o **mesmo evento pode chegar mais de uma vez**: trate as entregas de forma **idempotente**, deduplicando por um identificador de negócio presente no `data` (por exemplo, `waMessageId`). O header `X-Pepino-Webhook-Id` identifica o webhook cadastrado (é constante por webhook), então não serve sozinho para deduplicar eventos.

## Erros possíveis no cadastro [#erros-possíveis-no-cadastro]

O envelope de erro é `{ "error": { "code", "message" } }`.

| Código | Significado                                                                                                                                                                                                                                                                       |
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Corpo inválido. Casos: JSON malformado (`JSON inválido: ...`); `name` ausente (`nome obrigatório`); `url` inválida — esquema diferente de http/https, sem host, ou apontando para IP interno/privado (`URL: ...`); evento não suportado em `events` (`evento inválido: <valor>`). |
| `401`  | API key ausente, inválida, ou usada no host do modo errado (`pzk_live_` em `sandbox.*` ou vice-versa). Ver [Autenticação](/docs/get-started/authentication#erros-esperados).                                                                                                      |
| `403`  | Projeto inativo ou sem permissão.                                                                                                                                                                                                                                                 |
| `404`  | (No `DELETE`) webhook não encontrado neste Projeto — id inexistente, de outro Projeto, ou de outro modo.                                                                                                                                                                          |
| `500`  | Erro interno ao gerar o secret ou ao persistir o webhook.                                                                                                                                                                                                                         |

## Webhook ou WebSocket? [#webhook-ou-websocket]

Os dois transportes entregam **exatamente os mesmos eventos**, com o **mesmo envelope**. A escolha é sobre a garantia de entrega que você precisa:

|                  | Webhook                                               | WebSocket                                               |
| ---------------- | ----------------------------------------------------- | ------------------------------------------------------- |
| Endpoint público | Necessário                                            | Não precisa                                             |
| Entrega          | Com retry e backoff, até 6 tentativas                 | Só enquanto o cliente está conectado (sem buffer/retry) |
| Indicado para    | Integrações de backend que exigem garantia de entrega | Frontends, dashboards, protótipos                       |

Se você não tem um endpoint público — por exemplo, num frontend ou protótipo — pode receber os mesmos eventos por uma conexão [WebSocket](/docs/webhooks/websocket), sem expor uma URL. O webhook continua sendo a opção recomendada quando você precisa de **entrega garantida**. Os dois podem ser usados ao mesmo tempo: o WebSocket não substitui o webhook quando você precisa de durabilidade.

## Próximos passos [#próximos-passos]

* [Eventos](/docs/webhooks/events) — os três eventos disponíveis e o payload de cada um.
* [Assinatura](/docs/webhooks/signing) — como validar `X-Pepino-Signature` com o secret `whsec_`, com exemplos em Node.js, Python e Go.
* [Entrega e retry](/docs/webhooks/delivery) — a política de timeout, sucesso/falha e reenvio em detalhe.
* [WebSocket](/docs/webhooks/websocket) — receber os eventos em tempo real sem endpoint público.


# Assinatura HMAC (/docs/webhooks/signing)





O seu endpoint de webhook é uma URL pública: qualquer pessoa na internet pode mandar um `POST` para ela fingindo ser o PEPINO ZAP. A **assinatura HMAC** existe para você ter certeza de duas coisas em cada entrega:

1. **Autenticidade** — a requisição veio mesmo do PEPINO ZAP (e não de um terceiro que descobriu a sua URL).
2. **Integridade** — o corpo recebido não foi alterado no caminho.

Toda entrega de webhook é um `POST` assinado. Junto do corpo, o PEPINO ZAP envia o header `X-Pepino-Signature`, e o seu endpoint **recalcula a mesma assinatura** e compara. Se bater, a entrega é legítima; se não bater, descarte.

```http
X-Pepino-Signature: sha256=<hmac-hex>
```

<Callout type="info">
  Validar a assinatura é **opcional do ponto de vista da entrega** (o PEPINO ZAP envia o `POST` de
  qualquer forma), mas é **fortemente recomendado** em produção. Sem validar, você não tem como
  distinguir uma entrega real de uma forjada por quem descobrir a sua URL. Se preferir começar sem
  validar (ex.: em um protótipo), pule para [Como validar](#como-validar) quando estiver pronto.
</Callout>

## O que é o `X-Pepino-Signature` [#o-que-é-o-x-pepino-signature]

O valor do header tem sempre o formato `sha256=` seguido do HMAC em hexadecimal minúsculo:

```
sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

O trecho após `sha256=` é o resultado de:

```
HMAC-SHA256(secret, corpoBruto)
```

codificado em **hexadecimal** (64 caracteres `0-9a-f`), onde:

* **`secret`** é o valor com prefixo `whsec_` que você recebeu **uma única vez** ao cadastrar o webhook (`POST /v1/webhooks`). É a chave compartilhada entre você e o PEPINO ZAP — só vocês dois a conhecem, e é por isso que a assinatura prova a origem.
* **`corpoBruto`** são os **bytes crus** do body recebido, exatamente como chegaram pela rede — não o JSON re-serializado depois de um parse.

O `sha256=` é apenas um prefixo de versão do esquema, parte fixa do header — ele **faz parte da string** que você compara, não é separado do hash. Nos exemplos abaixo, o valor esperado é montado como `"sha256=" + hex(...)` justamente por isso.

### Headers presentes em toda entrega [#headers-presentes-em-toda-entrega]

Além da assinatura, todo `POST` de webhook traz estes headers, definidos pelo entregador:

| Header                | Exemplo                 | Para que serve                                                                                                                                                                                                                                                                                        |
| --------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`      | O corpo é sempre JSON.                                                                                                                                                                                                                                                                                |
| `User-Agent`          | `pepino-zap-webhooks/1` | Identifica o entregador. Útil para filtros/logs.                                                                                                                                                                                                                                                      |
| `X-Pepino-Event`      | `message.received`      | Nome do evento. Permite rotear sem fazer parse do corpo. Ver [Eventos](/docs/webhooks/events).                                                                                                                                                                                                        |
| `X-Pepino-Webhook-Id` | `wh_...`                | Identifica **qual webhook cadastrado** originou a entrega. É **constante** por webhook (não é um id por entrega) — útil quando um mesmo endpoint recebe de vários webhooks, para saber qual secret usar. **Não** serve para deduplicar (ver [Entrega e retry](/docs/webhooks/delivery#idempotencia)). |
| `X-Pepino-Signature`  | `sha256=<hmac-hex>`     | A assinatura HMAC-SHA256 do corpo bruto.                                                                                                                                                                                                                                                              |

<Callout type="warn">
  Calcule o HMAC sobre o **corpo bruto** (raw body), **antes** de qualquer parse. Se você fizer
  `JSON.parse` e depois re-serializar o objeto para calcular a assinatura, ela **não vai bater**:
  espaçamento, ordem de chaves e escaping podem mudar na re-serialização, e o HMAC é sensível a
  qualquer byte diferente. Capture os bytes crus do corpo primeiro, valide, e só então faça o parse.
</Callout>

## De onde vem o `secret` [#de-onde-vem-o-secret]

O `secret` é gerado e devolvido **no momento em que você cadastra o webhook**, via API, de forma self-service e escopado ao seu Projeto. O cadastro é feito com `POST /v1/webhooks` (ver [Webhooks](/docs/webhooks)):

```bash
curl -X POST https://api.pepinozap.com.br/v1/webhooks \
  -H "Authorization: Bearer $YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "meu-endpoint",
    "url": "https://meusistema.com/webhook/pepinozap",
    "events": []
  }'
```

Resposta `201 Created` — repare no campo `secret`:

```json
{
  "id": "wh_abc123",
  "name": "meu-endpoint",
  "url": "https://meusistema.com/webhook/pepinozap",
  "events": [],
  "enabled": true,
  "lastSuccessAt": null,
  "lastFailureAt": null,
  "lastFailureReason": null,
  "createdAt": "2026-06-05T12:00:00Z",
  "secret": "whsec_mfrggzdfmztwq2lknnwg23tpobyxe43uov3ho6dz"
}
```

Características do `secret`:

* Tem sempre o prefixo `whsec_`, seguido de uma sequência aleatória em minúsculas (caracteres `a-z2-7`). Trate-o como uma string opaca: **não** dependa do tamanho exato nem tente derivar nada dele.
* É **exibido uma única vez**, no corpo da resposta da criação. Nas consultas posteriores (`GET /v1/webhooks`) o `secret` **não** é retornado.
* Em modo teste o secret é cadastrado e usado da mesma forma; o que muda é o modo da API key (`pzk_test_`) e o host. Ver [Autenticação](/docs/get-started/authentication).

<Callout type="warn">
  **Guarde o `secret` assim que receber a resposta.** Ele não aparece de novo. Se
  você perdê-lo, **delete o webhook e crie outro** — isso gera um secret novo.
  Não há endpoint para "revelar" ou "rotacionar" o secret de um webhook
  existente: o caminho é deletar e recriar.

  Para remover: `DELETE /v1/webhooks/{id}` (retorna `204 No Content`). Para
  listar os seus webhooks (sem o secret): `GET /v1/webhooks`.
</Callout>

## Como validar [#como-validar]

A validação é simétrica: o PEPINO ZAP calcula `HMAC-SHA256(secret, corpoBruto)` e manda o resultado no header; você faz **exatamente o mesmo cálculo** com o seu secret e o corpo que recebeu, e compara as duas strings. O algoritmo, em qualquer linguagem, é:

1. **Leia o corpo bruto** da requisição (bytes), antes de qualquer parse.
2. Leia o header `X-Pepino-Signature` recebido.
3. Calcule `esperado = "sha256=" + hex(HMAC-SHA256(secret, corpoBruto))`.
4. Compare `esperado` com o header recebido usando **comparação de tempo constante**.
5. Se **não** bater, responda `401` e **descarte** a entrega. Se bater, processe o corpo e responda `2xx`.

<Callout type="warn">
  Use sempre uma função de **comparação de tempo constante** (`timingSafeEqual` no Node,
  `hmac.compare_digest` no Python, `hmac.Equal` no Go) — nunca `==` / `===` direto entre as strings.
  Comparações comuns retornam mais cedo no primeiro caractere diferente, o que vaza, pelo tempo de
  resposta, o quanto do hash estava certo. As funções de tempo constante eliminam esse canal
  lateral.
</Callout>

<Tabs items="['Node.js', 'Python', 'Go']">
  <Tab value="Node.js">
    ```javascript
    import crypto from 'node:crypto';

    // Recalcula o HMAC e compara em tempo constante.
    function verify(rawBody, signatureHeader, secret) {
      const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
      const a = Buffer.from(signatureHeader || '');
      const b = Buffer.from(expected);
      // timingSafeEqual exige buffers do mesmo tamanho — cheque antes.
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }

    // Express: é PRECISO capturar o raw body. express.json() descarta os bytes
    // crus; use express.raw() nesta rota (ou express.json({ verify })).
    app.post('/webhooks/pepino', express.raw({ type: 'application/json' }), (req, res) => {
      if (!verify(req.body, req.get('X-Pepino-Signature'), process.env.PEPINO_WEBHOOK_SECRET)) {
        return res.status(401).end();
      }
      const evt = JSON.parse(req.body.toString('utf8'));
      // processa evt.event / evt.instanceId / evt.data — responda 2xx rápido
      res.status(200).end();
    });
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import hmac, hashlib

    def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
        expected = "sha256=" + hmac.new(
            secret.encode(), raw_body, hashlib.sha256
        ).hexdigest()
        # compare_digest é tempo constante e tolera tamanhos diferentes.
        return hmac.compare_digest(expected, signature_header or "")

    # Flask
    @app.post("/webhooks/pepino")
    def webhook():
        raw = request.get_data()  # bytes crus, antes do parse
        if not verify(raw, request.headers.get("X-Pepino-Signature"), SECRET):
            return "", 401
        evt = request.get_json()
        # processa evt["event"] / evt["instanceId"] / evt["data"]
        return "", 200
    ```
  </Tab>

  <Tab value="Go">
    ```go
    func verify(rawBody []byte, sigHeader, secret string) bool {
        mac := hmac.New(sha256.New, []byte(secret))
        mac.Write(rawBody)
        expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
        return hmac.Equal([]byte(expected), []byte(sigHeader))
    }

    func handler(w http.ResponseWriter, r *http.Request) {
        raw, _ := io.ReadAll(r.Body) // bytes crus
        if !verify(raw, r.Header.Get("X-Pepino-Signature"), secret) {
            w.WriteHeader(http.StatusUnauthorized)
            return
        }
        // json.Unmarshal(raw, &evt) — processa e responde 2xx
        w.WriteHeader(http.StatusOK)
    }
    ```
  </Tab>
</Tabs>

<Callout type="info">
  **Captura do raw body por framework.** O ponto mais comum de falha é o
  framework consumir o corpo no parser de JSON e te entregar só o objeto. Garanta
  o acesso aos bytes crus: **Express** → `express.raw({ type: 'application/json' })`
  na rota (ou `express.json({ verify })`); **Fastify** → `addContentTypeParser`
  com `parseAs: 'buffer'`; **Flask** → `request.get_data()`; **Django** →
  `request.body`; &#x2A;*Go (net/http)** → `io.ReadAll(r.Body)`. Calcule o HMAC sobre
  esses bytes — não sobre o objeto já parseado.
</Callout>

## Testando a validação por fora [#testando-a-validação-por-fora]

Para conferir que a sua lógica de validação está correta antes de plugar no tráfego real, calcule a assinatura manualmente sobre um corpo de exemplo e confira que o seu endpoint a aceita. Com o secret e o corpo em mãos:

```bash
# secret e corpo de exemplo
SECRET="whsec_mfrggzdfmztwq2lknnwg23tpobyxe43uov3ho6dz"
BODY='{"event":"message.received","instanceId":"inst_123","timestamp":"2026-06-05T12:00:00Z","data":{}}'

# calcula o HMAC-SHA256 do corpo com o secret (hex minúsculo)
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')"

# manda pro seu endpoint, exatamente o mesmo corpo, com o header montado
curl -X POST https://meusistema.com/webhook/pepinozap \
  -H "Content-Type: application/json" \
  -H "X-Pepino-Signature: $SIG" \
  --data-raw "$BODY"
```

Se o seu endpoint responder `2xx`, a validação está correta. Troque um caractere do `BODY` (sem recalcular a assinatura) e repita: agora ele deve responder `401`, confirmando que a verificação está realmente ativa.

<Callout type="warn">
  Use `--data-raw` (não `--data`/`-d`) no `curl`: `-d` pode remover quebras de linha e alterar os
  bytes enviados, fazendo a assinatura não bater por um motivo que não é a sua lógica. O corpo usado
  no cálculo do HMAC tem que ser **byte a byte** o mesmo enviado na requisição.
</Callout>

## Boas práticas [#boas-práticas]

* **Responda rápido.** Retorne `2xx` em poucos segundos e processe de forma assíncrona. O timeout de cada entrega é de **15s**; respostas lentas ou fora da faixa `2xx` são tratadas como falha e disparam reentrega com backoff exponencial, em até **6 tentativas**. Ver [Entrega e retry](/docs/webhooks/delivery).
* **Seja idempotente.** Em retries, a mesma entrega pode chegar mais de uma vez. Deduplique por um identificador de **negócio** presente em `data` (como `waMessageId`) — **não** pelo `X-Pepino-Webhook-Id`, que é constante por webhook e o mesmo em todas as entregas. Ver [Entrega e retry](/docs/webhooks/delivery#idempotencia).
* **Guarde o secret fora do código.** Mantenha-o em variável de ambiente ou cofre de segredos, nunca commitado no repositório.
* **Valide antes de fazer parse.** Cheque a assinatura sobre os bytes crus e só então desserialize o JSON. Isso também protege o seu parser de payloads forjados.
* **Um secret por webhook.** Cada webhook cadastrado tem o seu próprio secret. Se você tem múltiplos endpoints, cada um valida com o secret que recebeu na sua criação.

## Erros e diagnóstico [#erros-e-diagnóstico]

A assinatura é validada **no seu lado** — o PEPINO ZAP não retorna erro de assinatura para você; quem decide aceitar ou rejeitar é o seu endpoint. Os sintomas abaixo são os que aparecem ao integrar:

| Sintoma                              | Causa provável                                               | O que fazer                                                                           |
| ------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| A assinatura **nunca** bate          | HMAC calculado sobre o corpo **já parseado/re-serializado**  | Calcule sobre o **raw body** (bytes crus), antes do parse.                            |
| A assinatura **nunca** bate          | Framework alterou os bytes (charset, trim, pretty-print)     | Capture os bytes exatos da requisição; não normalize o corpo.                         |
| A assinatura **nunca** bate          | Você comparou só o hash, esquecendo o prefixo `sha256=`      | Compare a string completa `"sha256=" + hex(...)` com o header inteiro.                |
| A assinatura **nunca** bate          | Secret errado (de outro webhook, ou de teste vs produção)    | Confira que o secret é o **daquele** webhook e do modo correto.                       |
| Funciona às vezes, falha às vezes    | Você está usando o secret de um webhook e recebendo de outro | Cada webhook tem secret próprio; identifique pelo `X-Pepino-Webhook-Id`.              |
| Erro de runtime no `timingSafeEqual` | Buffers de tamanhos diferentes (Node)                        | Cheque `a.length === b.length` antes de comparar (como no exemplo).                   |
| Perdeu o secret                      | O secret só é exibido na criação                             | **Delete o webhook e crie outro** (`DELETE /v1/webhooks/{id}` → `POST /v1/webhooks`). |

Erros que o **endpoint de cadastro** (`POST /v1/webhooks`) pode retornar, antes mesmo de existir um secret para validar — o envelope é `{ "error": { "code", "message" } }`:

* `400` — corpo JSON inválido, `name` ausente, `url` inválida (precisa ser `http`/`https` absoluta e não apontar para rede interna) ou um evento desconhecido em `events` (válidos: `message.received`, `message.status`, `instance.status`, `call.received`, `message.deleted`, `group.participants`, `group.updated`, `contact.updated`, `label.updated`, `label.association`; lista vazia = todos).
* `401` — API key ausente ou inválida (ou key de modo diferente do host: `pzk_test_` em produção, ou vice-versa).
* `404` — (em `DELETE /v1/webhooks/{id}`) webhook não encontrado neste Projeto.

## Próximos passos [#próximos-passos]

* [Webhooks](/docs/webhooks) — cadastrar, listar e remover seus webhooks self-service.
* [Eventos](/docs/webhooks/events) — a estrutura do envelope (`event`, `instanceId`, `timestamp`, `data`) e o payload de cada evento.
* [Entrega e retry](/docs/webhooks/delivery) — timeout, critério de sucesso/falha e política de reenvio.


# WebSocket (tempo real) (/docs/webhooks/websocket)





O WebSocket é um transporte **alternativo ao webhook** para receber eventos em tempo real. Em vez de a plataforma chamar o seu endpoint (como faz o webhook), é o **seu cliente que abre uma conexão persistente** com o PEPINO ZAP e passa a receber os eventos enquanto estiver conectado.

É a opção indicada quando você **não tem um endpoint público** para receber `POST` — por exemplo, um frontend, uma SPA, um dashboard interno ou um protótipo rodando na sua máquina. Como a conexão parte do seu lado, não há nada para expor à internet nem firewall para configurar.

<Callout type="warn">
  **Direto do navegador, só em contexto de confiança.** A autenticação é `?token=<API_KEY>` (ver
  [Autenticação](#autenticacao)) — então **qualquer pessoa com acesso àquela página vê a key** (aba de
  rede / JS) e ganha **acesso total à API**. Isso é aceitável para um **dashboard interno, uma
  ferramenta sua ou um protótipo** (você é o dono da key), mas **não** para um app **público /
  multi-usuário**. Nesse caso, mantenha a key no **servidor**: receba os eventos por
  [webhook](/docs/webhooks) e repasse ao browser pelo seu próprio canal, **ou*&#x2A; — sem manter socket de
  pé — o browser faz &#x2A;*[polling](/docs/crm/inbox)** no seu servidor, que já tem os dados do webhook.
  E para um app **multi-usuário*&#x2A; que ainda quer push direto no navegador, use o
  &#x2A;*[token efêmero](#token-efemero-para-o-navegador)**: o seu backend troca a key por um token curto e
  escopado, e o navegador usa **ele** — a `?token=` nunca é a key crua.
</Callout>

Os eventos entregues são **os mesmos** do webhook — os dez tipos de [Eventos](/docs/webhooks/events) (`message.received`, `message.status`, `instance.status`, `call.received`, `message.deleted`, `group.participants`, `group.updated`, `contact.updated`, `label.updated`, `label.association`) — com o **mesmo envelope JSON**. Além deles, o WebSocket entrega `presence.update` (online/digitando), **exclusivo** deste transporte (ver abaixo). Você não precisa aprender outro formato: o que você já sabe da página de Eventos vale aqui sem mudança.

<Callout type="info">
  O WebSocket **não** substitui o webhook quando você precisa de garantia de entrega. Ele entrega o
  evento apenas **enquanto o cliente está conectado** — não há buffer nem retry. Se a conexão
  estiver caída no instante do evento, esse evento **não** é reentregue depois. Para entrega durável
  (com reenvio em caso de falha), use [webhooks](/docs/webhooks). Os dois transportes podem rodar ao
  mesmo tempo.
</Callout>

## Endpoint [#endpoint]

A conexão é feita sobre o protocolo seguro `wss://` (WebSocket sobre TLS), no caminho `/v1/ws`:

| Modo            | URL                                    |
| --------------- | -------------------------------------- |
| Produção        | `wss://api.pepinozap.com.br/v1/ws`     |
| Teste (sandbox) | `wss://sandbox.pepinozap.com.br/v1/ws` |

O **host decide o modo** (produção ou teste), e a sua API key precisa **casar com o modo do host** — ver [Autenticação](#autenticacao) abaixo. Use sempre `wss://` (criptografado); conexões `ws://` em texto puro não são aceitas.

Uma conexão é escopada ao **Projeto** da API key e recebe os eventos de **todas as instâncias** desse Projeto — você não filtra por instância na URL. Para distinguir de qual instância veio cada evento, use o campo `instanceId` do envelope (ver [Envelope dos eventos](#envelope-dos-eventos)).

## Autenticação [#autenticação]

O handshake de um WebSocket no navegador &#x2A;*não permite enviar o header `Authorization`** (a API `WebSocket` não aceita headers customizados). Por isso, em vez do header `Authorization: Bearer <API_KEY>` usado nos demais endpoints, a API key é passada no &#x2A;*parâmetro de query `token`**:

```
wss://api.pepinozap.com.br/v1/ws?token=pzk_live_xxxxxxxxxxxxxxxx
```

Use a **mesma API key** que você usaria no header `Authorization`. A key precisa ser coerente com o modo do host:

| Host                       | Modo     | Prefixo de key exigido |
| -------------------------- | -------- | ---------------------- |
| `api.pepinozap.com.br`     | Produção | `pzk_live_`            |
| `sandbox.pepinozap.com.br` | Teste    | `pzk_test_`            |

Conectar com uma key do modo errado (por exemplo, `pzk_test_` no host de produção) é rejeitado com `401`. O modo é resolvido pelo host; a key apenas tem que combinar.

<Callout type="warn">
  Como a key vai na **URL**, evite registrá-la em logs de acesso, no histórico do navegador ou no
  header `Referer`. Para testes, prefira sempre o ambiente de **sandbox** com uma key `pzk_test_`.
  Se uma key de produção for exposta dessa forma, **revogue-a** e gere outra no painel.
</Callout>

### O que pode falhar no handshake [#o-que-pode-falhar-no-handshake]

A autenticação acontece **durante o handshake HTTP**, antes do upgrade para WebSocket. Quando ela falha, o servidor responde com um status HTTP normal (a conexão WebSocket nem chega a abrir) e o corpo segue o envelope de erro padrão da API, `{ "error": { "code", "message" } }`:

| Status | `code`         | Quando acontece                                                                                                                                                                                                         |
| ------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | `UNAUTHORIZED` | `?token=` ausente; key inválida, revogada ou expirada; ou key do modo errado para o host (`pzk_live_` no sandbox, ou vice-versa).                                                                                       |
| `403`  | `FORBIDDEN`    | A key é válida, mas o **Projeto não está ativo**.                                                                                                                                                                       |
| `426`  | —              | A `?token=` foi aceita, mas a requisição chegou em `/v1/ws` **sem cabeçalhos de upgrade** de WebSocket (por exemplo, um `GET` comum, com a key na query, mas sem fazer o upgrade). Use um cliente WebSocket de verdade. |

<Callout type="warn">
  No navegador, a API `WebSocket` **não expõe o status nem o corpo** da resposta de falha do
  handshake — você recebe apenas um evento `onclose`/`onerror` genérico, sem o motivo. Se a conexão
  fechar logo após abrir, valide o `?token=` e o host com uma ferramenta que mostre o corpo HTTP
  (por exemplo, `curl` apontando para o endpoint via `http(s)://` no lugar de `wss://`, ou um
  cliente WebSocket de linha de comando). Em produção, faça um pré-teste do par host + key em
  sandbox antes de subir.
</Callout>

## Token efêmero para o navegador [#token-efêmero-para-o-navegador]

Passar a `?token=<API_KEY>` direto no navegador expõe a key — tudo bem num dashboard interno, mas **não** num app **público/multi-usuário**, onde qualquer pessoa na página pegaria a key e teria acesso total à API. Para esse caso, o seu **backend** troca a key por um **token efêmero** e entrega só **ele** ao navegador.

**1. No seu servidor** (com a API key, que nunca vai ao browser), peça um token:

```
POST /v1/ws/token
Authorization: Bearer pzk_live_xxxxxxxxxxxxxxxx
```

Resposta:

```json
{ "token": "eyJhbGciOi...", "tokenType": "ws", "expiresIn": 900 }
```

Esse token vale **\~15 min** (`expiresIn`, em segundos), é **escopado ao Projeto** da key e **só serve para abrir este WebSocket** — não autentica nenhum outro endpoint. Vazou? No máximo abre um WS de leitura daquele Projeto, e expira sozinho.

**2. No navegador**, conecte com o token efêmero no lugar da key:

```
wss://api.pepinozap.com.br/v1/ws?token=eyJhbGciOi...
```

**3. Reconexão sem queda.** O token é checado **só no handshake** — a conexão já aberta **não cai** quando ele expira. Se o socket cair (rede) e o token já tiver expirado, peça um `/v1/ws/token` novo ao seu backend **antes de reconectar** (1 chamada, transparente para o usuário final).

<Callout type="info">
  É o mesmo princípio do **painel da PEPINO ZAP**: o navegador nunca carrega uma credencial de acesso
  total — só um token curto, escopado e renovável; a API key crua fica **sempre no servidor**.
</Callout>

## Filtrar por evento [#filtrar-por-evento]

Por padrão, a conexão recebe **todos** os eventos do Projeto. Para receber apenas alguns, passe o parâmetro `events` na URL, com os nomes separados por vírgula — os **mesmos nomes** usados no webhook (ver [Eventos](/docs/webhooks/events)). Sem o parâmetro (ou com a lista vazia), você recebe todos.

```
wss://api.pepinozap.com.br/v1/ws?token=pzk_live_xxxx&events=message.received,call.received
```

| `events` na URL                   | A conexão recebe      |
| --------------------------------- | --------------------- |
| *(ausente)*                       | Todos os eventos      |
| `message.received`                | Só `message.received` |
| `message.received,message.status` | Os dois listados      |

O filtro vale **só para esta conexão** (é por conexão, não por Projeto) — abra conexões diferentes, com filtros diferentes, se precisar separar fluxos. Nomes desconhecidos são ignorados (não casam com nada). O frame inicial `connected` é sempre enviado, independente do filtro.

## Envelope dos eventos [#envelope-dos-eventos]

Cada evento chega como um **frame de texto** com um JSON **idêntico** ao corpo entregue pelo webhook — mesmo formato, mesmos campos:

```json
{
  "event": "message.received",
  "instanceId": "cm5x8a9k20001abc123def456",
  "timestamp": "2026-05-30T12:00:00Z",
  "data": {}
}
```

Campos do envelope:

* `event` — nome do evento (um dos dez tipos de webhook — `message.received`, `message.status`, `instance.status`, `call.received`, `message.deleted`, `group.participants`, `group.updated`, `contact.updated`, `label.updated`, `label.association` — ou `presence.update`, exclusivo do WebSocket; ver [Eventos](/docs/webhooks/events)).
* `instanceId` — identificador da instância que originou o evento (use para saber de qual número veio, já que uma conexão recebe todas as instâncias do Projeto).
* `timestamp` — data e hora do evento em ISO 8601 (UTC).
* `data` — objeto com os dados específicos do evento.

O conteúdo de `data` &#x2A;*varia conforme o `event`**. Os três eventos e o payload completo de cada um (incluindo os formatos de `from`, os valores de `status` e o campo `mediaUrl`) estão documentados em [Eventos](/docs/webhooks/events) — são os mesmos aqui.

### Frame de confirmação inicial (`connected`) [#frame-de-confirmação-inicial-connected]

**Logo após o handshake**, antes de qualquer evento, o servidor envia um frame de confirmação informando o Projeto ao qual a conexão foi escopada:

```json
{ "event": "connected", "projectId": "proj_123" }
```

Esse é o sinal de que a conexão está **autenticada e pronta** para receber eventos. Trate-o como um caso à parte no seu `onmessage`: ele **não** tem o envelope de evento (`instanceId`, `timestamp`, `data`), então não tente processá-lo como uma mensagem. Receber o `connected` é a confirmação de que o `?token=` foi aceito.

### Frame de erro (`error`) [#frame-de-erro-error]

Em uma falha de autorização detectada **após** o upgrade, o servidor pode enviar um frame de erro e encerrar a conexão:

```json
{ "event": "error", "message": "unauthorized" }
```

Na prática, as falhas de credencial são barradas ainda no handshake (ver [O que pode falhar no handshake](#o-que-pode-falhar-no-handshake)); este frame é a salvaguarda para o caso de o contexto de autorização não chegar ao handler. Se você receber um frame com `event: "error"`, **não reconecte em loop** sem antes revalidar o `?token=` — provavelmente a credencial está incorreta.

<Callout type="info">
  Como diferenciar os frames no seu `onmessage`: faça `JSON.parse` e olhe o campo `event`. Se for
  `connected`, é a confirmação inicial (ignore). Se for `error`, é uma falha (revise a credencial).
  Qualquer outro valor (`message.received`, `message.status`, `instance.status`) é um evento real
  para processar.
</Callout>

## Ciclo de vida da conexão [#ciclo-de-vida-da-conexão]

Entender o ciclo de vida evita os dois erros mais comuns: achar que precisa "fazer keepalive" manualmente e não tratar a reconexão.

* **Ping / keepalive (servidor → cliente).** O servidor envia um **frame de controle `ping` a cada 25 segundos** para manter a conexão viva e detectar quedas. Navegadores e a grande maioria das bibliotecas WebSocket **respondem o `pong` automaticamente** — você não precisa fazer nada. Esse ping é um frame de **controle** do protocolo WebSocket, não um frame de texto: ele **não** dispara o seu `onmessage`, então você nunca vai ver um evento `"ping"` no payload (diferente do stream SSE de QR, que emite um evento `ping` de texto).
* **Canal de saída apenas (servidor → cliente).** O cliente **não precisa enviar nenhuma mensagem** — o canal é unidirecional na prática. O servidor lê os frames que você porventura enviar só para detectar o fechamento da conexão; nada que você mande é interpretado como comando.
* **Reconexão.** Se a conexão cair (queda de rede, deploy, timeout de proxy), **reconecte e continue recebendo**. Como não há buffer, qualquer evento ocorrido **durante** o intervalo desconectado é perdido — por isso a reconexão deve ser rápida. Recomenda-se um **backoff exponencial** (por exemplo, 1s, 2s, 4s, 8s… até um teto de \~30s) para não martelar o servidor numa indisponibilidade.

<Callout type="warn">
  Proxies e balanceadores podem encerrar conexões ociosas. O ping de 25s do servidor normalmente
  mantém a conexão viva nesses intermediários, mas se você observar quedas periódicas atrás de um
  proxy corporativo, a causa costuma ser um timeout de idle do proxy — a reconexão com backoff
  resolve do lado do cliente. Para um cenário onde **nenhum** evento pode ser perdido, o transporte
  recomendado é o [webhook](/docs/webhooks/delivery) (com retry).
</Callout>

## Exemplo completo (JavaScript) [#exemplo-completo-javascript]

O exemplo abaixo cobre o caso real: confirma o `connected`, processa os eventos por tipo, trata o frame de `error` e reconecta com backoff exponencial.

```js
const TOKEN = 'pzk_live_xxxxxxxxxxxxxxxx';
const URL = `wss://api.pepinozap.com.br/v1/ws?token=${TOKEN}`;

let backoff = 1000; // 1s inicial
const MAX_BACKOFF = 30000; // teto de 30s

function connect() {
  const ws = new WebSocket(URL);

  ws.onopen = () => {
    // Handshake aceito. O servidor enviará o frame "connected" em seguida.
  };

  ws.onmessage = (e) => {
    const evt = JSON.parse(e.data);

    if (evt.event === 'connected') {
      backoff = 1000; // conexão saudável: zera o backoff
      return;
    }
    if (evt.event === 'error') {
      // Falha de autorização: revise o ?token= antes de reconectar.
      console.error('WS error:', evt.message);
      return;
    }

    // Evento real (mesmo envelope do webhook).
    switch (evt.event) {
      case 'message.received':
        console.log('Mensagem de', evt.instanceId, evt.data);
        break;
      case 'message.status':
        console.log('Status de entrega', evt.data.status, evt.data);
        break;
      case 'instance.status':
        console.log('Conexão da instância', evt.data.status);
        break;
    }
  };

  ws.onclose = () => {
    // Reconecta com backoff exponencial.
    setTimeout(connect, backoff);
    backoff = Math.min(backoff * 2, MAX_BACKOFF);
  };

  ws.onerror = () => {
    ws.close(); // força o onclose e o fluxo de reconexão
  };
}

connect();
```

No modo teste, troque a URL para `wss://sandbox.pepinozap.com.br/v1/ws` e use uma key `pzk_test_`.

## Presença ao vivo — `presence.update` (exclusivo do WebSocket) [#presença-ao-vivo--presenceupdate-exclusivo-do-websocket]

Indicadores de **presença** — "digitando…", "gravando áudio…", parou de digitar e (online/offline) — são um sinal de **altíssima frequência e efêmero**. Por isso, ao contrário dos demais eventos, o `presence.update` é entregue **apenas pelo WebSocket**, ao vivo: **não** vai por webhook e **não** é persistido. Se ninguém está conectado ao WS naquele instante, ele é descartado, sem custo. É o que mantém a plataforma leve e evita inundar a fila de webhooks com ruído.

```json
{
  "event": "presence.update",
  "instanceId": "inst_123",
  "timestamp": "2026-06-06T12:00:00Z",
  "data": {
    "from": "5511999999999@s.whatsapp.net",
    "presence": "typing"
  }
}
```

| Campo      | Tipo   | Descrição                                                                                                                     |
| ---------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `from`     | string | O contato/chat de onde vem a presença.                                                                                        |
| `presence` | string | `typing` (digitando), `recording` (gravando áudio), `paused` (parou de digitar), `online` ou `offline`.                       |
| `lastSeen` | string | Só em `offline`, quando disponível: o "visto por último" em ISO 8601 (UTC). Ausente se o contato oculta o "visto por último". |

<Callout type="info">
  Os indicadores de **digitação/gravação** chegam naturalmente para conversas ativas. Já
  **online/offline** o WhatsApp só envia para contatos que você **assina** explicitamente — a
  plataforma não assina presença em massa por padrão (justamente para se manter leve), então, na
  prática, você verá sobretudo `typing` / `recording` / `paused`. Para receber **só** presença,
  conecte com `?events=presence.update` (ver [Filtrar por evento](#filtrar-por-evento)).
</Callout>

## WebSocket ou webhook? [#websocket-ou-webhook]

As duas opções entregam **os mesmos eventos**, com o mesmo envelope. A escolha depende de **dois fatores**: se você tem um endpoint público e qual garantia de entrega você precisa.

|                           | WebSocket                                         | Webhook                                    |
| ------------------------- | ------------------------------------------------- | ------------------------------------------ |
| Endpoint público          | **Não precisa** (o cliente conecta)               | **Necessário** (a plataforma chama você)   |
| Sentido da conexão        | Cliente → plataforma                              | Plataforma → seu endpoint                  |
| Autenticação              | `?token=` na URL                                  | Header + assinatura `X-Pepino-Signature`   |
| Entrega                   | Só enquanto conectado — **sem buffer, sem retry** | Com retry e backoff, **até 6 tentativas**  |
| Eventos perdidos na queda | Perdidos (não há reentrega)                       | Reenviados até confirmação `2xx`           |
| Indicado para             | Frontends, dashboards, protótipos                 | Integrações de backend que exigem garantia |

Regra prática: use **WebSocket** quando o consumidor é um frontend ou ferramenta sem URL pública e a perda eventual de um evento é tolerável; use **webhook** quando você precisa que **todo** evento chegue, mesmo após uma queda. Nada impede usar **os dois ao mesmo tempo** — por exemplo, webhook como fonte durável no backend e WebSocket para atualizar uma tela ao vivo.

Para o comportamento detalhado de entrega e reenvio do webhook (timeout, número de tentativas, idempotência), consulte [Entrega e retry](/docs/webhooks/delivery). Para validar a origem das entregas de webhook, veja [Assinatura](/docs/webhooks/signing).

## Próximos passos [#próximos-passos]

* [Eventos](/docs/webhooks/events) — os três eventos e o payload completo de cada `data`.
* [Webhooks](/docs/webhooks) — o transporte com entrega garantida (retry) e como cadastrá-los via API.
* [Entrega e retry](/docs/webhooks/delivery) — timeout, backoff e idempotência das entregas por webhook.


---

# Referência completa da API (OpenAPI 3.1)

A especificação abaixo descreve TODOS os endpoints, parâmetros, corpos de requisição, respostas e schemas. É a fonte canônica e legível por máquina — cole-a num gerador de cliente OpenAPI ou numa IA para montar as chamadas.

```yaml
openapi: 3.1.0
info:
  title: PEPINO ZAP API
  version: v1
  description: >-
    API REST do PEPINO ZAP, gateway WhatsApp multi-tenant. Toda requisicao e
    autenticada por API key escopada a um Projeto, enviada no header
    Authorization com o esquema Bearer.
servers:
  - url: https://api.pepinozap.com.br
    description: Producao (keys pzk_live_)
  - url: https://sandbox.pepinozap.com.br
    description: Teste (keys pzk_test_; gratis, teto 100 msgs/dia)
security:
  - bearerAuth: []
tags:
  - name: Instancias
    description: Criar, listar, consultar e remover instancias de WhatsApp.
  - name: Mensagens
    description: >-
      Enviar texto, midia (imagem, video, audio e documento), localizacao,
      contato, sticker, reacoes, enquetes e marcar mensagens como lidas.
  - name: Campanhas
    description: >-
      Disparo em massa (broadcast) com CADENCIA ANTI-BAN. Crie a campanha
      (rascunho) com a mensagem-template ({{nome}} e variaveis por destinatario),
      a lista e a configuracao de cadencia; depois dispare. O envio roda em
      background respeitando: warmup (teto diario crescente), janela horaria com
      timezone, ritmo (pacing) com jitter, auto-pausa apos N falhas/invalidos
      consecutivos e validacao do numero (marca invalidos sem gastar envio). O
      status de cada destinatario tambem chega em tempo real pelo evento
      campaign.status (webhook/WebSocket). RISCO: disparo em massa pode levar ao
      BANIMENTO do numero pela Meta — use listas com opt-in, conteudo nao-spam e
      volume moderado. O uso e LIBERADO conforme o que a CONTA adquiriu, nao por
      ter API key: criar/editar exige o add-on "Disparo em massa"; disparar/
      retomar exige tambem assinatura ativa (402 sem isso, em producao). No modo
      teste e liberado so para validar a integracao.
  - name: Inbox
    description: >-
      Historico persistido de conversas e mensagens. O PEPINO ZAP e o system of
      record: guardamos todo o historico (texto + midia) na nossa infra. Voce le
      direto destes endpoints e NAO precisa de banco de dados nem storage
      proprios. O historico sobrevive a deletar/reconectar a instancia.
  - name: Webhooks
    description: >-
      Cadastre e gerencie seus webhooks (self-service, escopo do seu Projeto)
      para receber eventos de mensagem, instancia, chamada, grupo, contato e
      etiqueta (10 tipos no total — ver o guia de Webhooks). Sem depender do
      operador.
  - name: Relatorios
    description: >-
      Metricas de gestao do atendimento (CRM) agregadas pela plataforma:
      receptivo x ativo, abertas/pendentes/resolvidas, tempo de primeira
      resposta, novos contatos e produtividade por atendente. Saem prontas
      (voce nao reimplementa heuristica). Janela em datas no fuso de Brasilia.
  - name: Etiquetas
    description: >-
      Etiquetas (tags) para classificar conversas. CRUD da paleta de etiquetas
      do Projeto e atribuicao M:N a cada conversa. Util para segmentar e
      filtrar o Inbox a partir do seu sistema.
  - name: Departamentos
    description: >-
      Departamentos para organizar a equipe (Suporte, Financeiro...). Cada
      atendente fica vinculado a um departamento; a conversa "esta" em um
      departamento e pode ser transferida entre eles num clique. CRUD do
      catalogo do Projeto + transferencia da conversa. A conversa sem
      departamento fica na fila geral (visivel a todos).
  - name: Justificativas
    description: >-
      Justificativas (motivos) de encerramento de conversa. Catalogo do
      Projeto; o motivo escolhido fica registrado ao resolver a conversa
      (POST /v1/inbox/conversations/{id}/status).
  - name: Fluxos
    description: >-
      Construtor de chatbot: fluxos automaticos por instancia (numero). Cada
      fluxo tem nos (message, menu, input, condition, delay, action, transfer,
      close) e roda no recebimento, antes do roteamento humano. CRUD de fluxos
      e nos; o editor visual do painel usa estes mesmos endpoints.
  - name: Funil
    description: >-
      Funil de vendas CONFIGURAVEL: cada ETAPA e uma REGRA sobre a conversa
      (origem, status, etiqueta, posicao no fluxo de bot). A conversa cai na
      etapa de MAIOR posicao cuja regra satisfaz (formato de funil, com queda
      entre etapas). CRUD de funis e etapas; GET /data devolve a contagem e as
      conversas por etapa, avaliadas em tempo de leitura.
paths:
  /v1/instances:
    post:
      tags: [Instancias]
      summary: Cria uma instancia
      description: >-
        Cria uma nova instancia (numero de WhatsApp) no Projeto da API key. A
        instancia nasce no estado DISCONNECTED, sem numero pareado (phoneNumber
        e jid nulos); para coloca-la online voce abre o QR Code em seguida (via
        qr/poll ou stream SSE) e escaneia no celular. O nome e apenas um rotulo
        e nao precisa ser unico — nomes podem repetir no mesmo Projeto. Sujeito a
        cota: cada Projeto so pode criar ate o numero de instancias do seu plano
        (numeros contratados); estourar a cota retorna 409. Criar instancia exige
        assinatura ativa (402 se nao houver). Retorna a instancia criada (201).
      operationId: createInstance
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateInstanceRequest'
            example:
              name: minha-instancia
      responses:
        '201':
          description: Instancia criada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Instance'
              example:
                id: cm5x8a9k20001abc123def456
                projectId: cm5x000proj0001xyz
                name: minha-instancia
                status: DISCONNECTED
                phoneNumber: null
                jid: null
                createdAt: '2026-05-30T12:00:00Z'
                updatedAt: '2026-05-30T12:00:00Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
    get:
      tags: [Instancias]
      summary: Lista as instancias do projeto
      description: >-
        Lista todas as instancias do Projeto da API key, no modo (teste ou
        producao) correspondente a key usada — uma key de teste so enxerga
        instancias de teste e vice-versa. Retorna um array (vazio se nao houver
        nenhuma), cada item no mesmo formato de GET /v1/instances/{id}, incluindo
        o status atual e, quando ja pareada, phoneNumber e jid. Sem paginacao.
      operationId: listInstances
      responses:
        '200':
          description: Array de instancias.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Instance'
              example:
                - id: cm5x8a9k20001abc123def456
                  projectId: cm5x000proj0001xyz
                  name: minha-instancia
                  status: CONNECTED
                  phoneNumber: '5511999999999'
                  jid: 5511999999999@s.whatsapp.net
                  createdAt: '2026-05-30T12:00:00Z'
                  updatedAt: '2026-05-30T12:05:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/instances/{id}:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [Instancias]
      summary: Detalhe de uma instancia
      description: >-
        Retorna o detalhe de uma instancia especifica do Projeto da API key. Use
        para consultar o status atual (notadamente confirmar status==CONNECTED
        antes de enviar mensagens) e, quando ja pareada, o numero (phoneNumber) e
        o JID. So enxerga instancias do mesmo Projeto e do mesmo modo (teste ou
        producao) da key; caso contrario responde 404 (nao distingue inexistente
        de pertencente a outro tenant/modo, por isolamento).
      operationId: getInstance
      responses:
        '200':
          description: Objeto da instancia.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Instance'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Instancias]
      summary: Remove uma instancia
      description: >-
        Remove a instancia e encerra sua sessao do WhatsApp (o numero desconecta
        do dispositivo). Operacao definitiva e sem corpo na resposta (204). O
        historico de conversas e mensagens dessa instancia PERMANECE no Inbox
        (sobrevive a delecao); apenas a instancia em si deixa de existir e o
        numero abre uma vaga na cota do plano. So remove instancia do mesmo
        Projeto e modo da key; caso contrario responde 404.
      operationId: deleteInstance
      responses:
        '204':
          description: Instancia removida (sem corpo).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/instances/{id}/settings:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    patch:
      tags: [Instancias]
      summary: Atualiza as configuracoes da instancia
      description: >-
        Atualiza as configuracoes de comportamento da instancia — pequenos
        liga/desliga aplicados ao numero conectado: rejeitar chamadas
        automaticamente (e responder quem ligou), ignorar mensagens de grupo,
        aparecer sempre online e marcar mensagens recebidas como lidas
        automaticamente.


        E um PATCH PARCIAL: envie apenas os campos que quer alterar; os campos
        omitidos permanecem com o valor atual (nao sao zerados). Todos sao
        opcionais e o padrao de cada um e desligado (false / texto vazio), ou
        seja, o comportamento neutro do WhatsApp.


        As configuracoes sao persistidas e aplicadas ao socket do WhatsApp em ate
        alguns segundos (no proximo ciclo de reconciliacao do worker) e continuam
        valendo apos quedas, reconexoes e deploys, sem precisar reenviar. Retorna
        a instancia completa, ja com o objeto "settings" atualizado.
      operationId: updateInstanceSettings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateInstanceSettingsRequest'
            example:
              rejectCalls: true
              rejectCallMessage: No momento nao atendemos chamadas. Envie uma mensagem que respondemos por aqui.
              ignoreGroups: true
              alwaysOnline: false
              readReceipts: true
      responses:
        '200':
          description: Instancia com as configuracoes atualizadas.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Instance'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/instances/{id}/qr-token:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Instancias]
      summary: Emite um token efemero para o stream de QR
      description: >-
        Emite um token de curta duracao (2 minutos) usado para abrir o stream
        SSE de QR Code sem expor a API key completa na URL. Util quando o
        EventSource roda no navegador. Passe o token retornado no parametro de
        query token do endpoint de stream.
      operationId: createQrToken
      responses:
        '200':
          description: Token efemero emitido.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QrTokenResponse'
              example:
                token: <token-jwt-efemero>
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/instances/{id}/qr/poll:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [Instancias]
      summary: Poll do QR Code e status (alternativa robusta ao SSE)
      description: >-
        Retorna o snapshot atual do QR Code e do status da instancia. Diferente
        do stream SSE, cada requisicao e curta e independente, atravessando
        antivirus e proxies corporativos que bufferizam streams (a causa mais
        comum de o QR Code nao aparecer). Recomendado: faca polling a cada ~2
        segundos ate connected ser true. Autentica pelo header Authorization
        normal (nao usa token de query).
      operationId: pollInstanceQR
      responses:
        '200':
          description: Snapshot do QR Code e do status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  connected:
                    type: boolean
                    description: true quando a instancia ja esta CONNECTED (pare o polling).
                  qr:
                    type: object
                    nullable: true
                    description: QR Code atual enquanto nao conectada; null caso contrario.
                    properties:
                      code:
                        type: string
                        description: Conteudo a renderizar como QR Code.
                      at:
                        type: string
                        format: date-time
                        description: Momento em que o QR Code foi gerado.
              example:
                connected: false
                qr:
                  code: '2@abc123...'
                  at: '2026-06-03T10:30:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/instances/{id}/qr:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    get:
      tags: [Instancias]
      summary: Stream SSE de QR Code e status
      description: >-
        Abre um stream Server-Sent Events (SSE) com o QR Code de pareamento e as
        transicoes de status, ao vivo. Ao conectar, ja solicita ao worker que
        gere o QR na hora (sem esperar o ciclo de reconciliacao). Como o
        EventSource do navegador nao envia headers, este endpoint autentica via
        parametro de query token em vez do header Authorization — passe a propria
        API key OU o token efemero emitido por POST /v1/instances/{id}/qr-token.
        Emite quatro tipos de evento: "ready" (handshake inicial, confirma que o
        stream esta vivo), "qr" (cada novo QR Code gerado, com {code, at}; renderize
        o code como QR Code), "status" (transicoes de estado da instancia, ex.
        CONNECTED/PAIRED — pare ao conectar) e "ping" (keepalive vazio a cada 25s
        para atravessar proxies). OBSERVACAO: antivirus e proxies corporativos que
        bufferizam streams podem impedir a entrega do QR; nesse caso prefira o
        endpoint de poll (GET /v1/instances/{id}/qr/poll).
      operationId: streamInstanceQR
      security: []
      parameters:
        - name: token
          in: query
          required: true
          description: API key passada na query string (apenas neste endpoint SSE).
          schema:
            type: string
            example: pzk_live_xxxxxxxxxxxxxxxx
      responses:
        '200':
          description: Stream de eventos SSE.
          content:
            text/event-stream:
              schema:
                type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/instances/{id}/messages:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Envia uma mensagem de texto
      description: >-
        Enfileira o envio de uma mensagem de texto. O envio e assincrono: a
        resposta 202 confirma apenas que a mensagem foi ENFILEIRADA, nao que foi
        entregue (a confirmacao de entrega/leitura chega pelo webhook
        message.status). IMPORTANTE: a instancia precisa estar CONNECTED — enviar
        para uma instancia que NAO esteja conectada tambem retorna 202, mas a
        mensagem nao sai (fica em espera indefinida) e nao ha evento de falha.
        Confirme status==CONNECTED (via GET /v1/instances/{id} ou qr/poll
        connected==true) ANTES de enviar.
      operationId: sendTextMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendTextMessageRequest'
            example:
              to: '5511999999999'
              body: Mensagem de teste
      responses:
        '202':
          description: Mensagem enfileirada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedTextResponse'
              example:
                queued: true
                to: '5511999999999'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/media:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Envia uma mensagem de midia
      description: >-
        Enfileira o envio de uma mensagem de midia. O arquivo deve estar
        acessivel em uma URL publica informada em mediaUrl. O envio e
        assincrono (resposta 202).
      operationId: sendMediaMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMediaMessageRequest'
            example:
              to: '5511999999999'
              type: image
              mediaUrl: https://exemplo.com/arquivo.jpg
              caption: Mensagem de teste
      responses:
        '202':
          description: Mensagem de midia enfileirada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedMediaResponse'
              example:
                queued: true
                to: '5511999999999'
                type: image
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/media-upload:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Envia midia por upload direto (multipart)
      description: >-
        Alternativa ao /messages/media: em vez de uma URL publica, envia o
        ARQUIVO direto via multipart/form-data. Util pra quem NAO quer hospedar a
        midia publicamente — nos subimos pro nosso storage e enfileiramos o envio.
        Limite de 16MB. Mesmas regras de plano/credito dos demais envios.
      operationId: sendMediaUpload
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [to, file]
              properties:
                to:
                  type: string
                  description: Destino. E.164 apenas com digitos ou JID completo.
                file:
                  type: string
                  format: binary
                  description: Arquivo a enviar (max 16MB).
                type:
                  type: string
                  description: >-
                    Opcional. Forca o tipo; senao e derivado do content-type do
                    arquivo.
                  enum: [image, video, audio, document]
                caption:
                  type: string
                  description: Legenda opcional.
                ptt:
                  type: string
                  description: '"true" envia o audio como nota de voz (so com type audio).'
      responses:
        '202':
          description: Midia enfileirada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedMediaResponse'
              example:
                queued: true
                to: '5511999999999'
                type: image
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          description: Arquivo maior que 16MB.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Uploads desabilitados neste ambiente.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/location:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Envia uma localizacao
      description: >-
        Enfileira o envio de uma mensagem de localizacao (mapa). Informe
        latitude e longitude; name e address sao rotulos opcionais. O envio e
        assincrono (resposta 202).
      operationId: sendLocationMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendLocationMessageRequest'
            example:
              to: '5511999999999'
              latitude: -23.55052
              longitude: -46.633308
              name: Praca da Se
              address: Praca da Se, Se, Sao Paulo - SP
      responses:
        '202':
          description: Localizacao enfileirada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedTextResponse'
              example:
                queued: true
                to: '5511999999999'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/contact:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Envia um contato
      description: >-
        Enfileira o envio de um cartao de contato (vCard) com nome e telefone.
        O envio e assincrono (resposta 202).
      operationId: sendContactMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendContactMessageRequest'
            example:
              to: '5511999999999'
              name: Joao da Silva
              phone: '5511988887777'
      responses:
        '202':
          description: Contato enfileirado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedTextResponse'
              example:
                queued: true
                to: '5511999999999'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/sticker:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Envia um sticker
      description: >-
        Enfileira o envio de um sticker. O arquivo deve ser um webp acessivel
        em uma URL publica informada em mediaUrl. O envio e assincrono
        (resposta 202).
      operationId: sendStickerMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendStickerMessageRequest'
            example:
              to: '5511999999999'
              mediaUrl: https://exemplo.com/figurinha.webp
      responses:
        '202':
          description: Sticker enfileirado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedStickerResponse'
              example:
                queued: true
                to: '5511999999999'
                type: sticker
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/reaction:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Reage a uma mensagem
      description: >-
        Enfileira uma reacao (emoji) a uma mensagem existente. Envie emoji vazio
        para remover a reacao. messageId identifica a mensagem alvo e fromMe
        indica se ela foi enviada pela propria instancia. O envio e assincrono
        (resposta 202).
      operationId: sendReactionMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendReactionMessageRequest'
            example:
              to: '5511999999999'
              messageId: 3EB0XXXXXXXXXXXXXXXX
              emoji: "\U0001F44D"
              fromMe: false
      responses:
        '202':
          description: Reacao enfileirada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedTextResponse'
              example:
                queued: true
                to: '5511999999999'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/poll:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Envia uma enquete
      description: >-
        Enfileira o envio de uma enquete (poll nativa do WhatsApp) com a
        pergunta em name e as alternativas em options (de 2 a 12).
        selectableCount define quantas opcoes o destinatario pode marcar
        (1 = escolha unica). O envio e assincrono (resposta 202).
      operationId: sendPollMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendPollMessageRequest'
            example:
              to: '5511999999999'
              name: Qual o melhor horario para o atendimento?
              options:
                - Manha
                - Tarde
                - Noite
              selectableCount: 1
      responses:
        '202':
          description: Enquete enfileirada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedTextResponse'
              example:
                queued: true
                to: '5511999999999'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'
  /v1/instances/{id}/messages/read:
    parameters:
      - $ref: '#/components/parameters/InstanceId'
    post:
      tags: [Mensagens]
      summary: Marca mensagens como lidas
      description: >-
        Enfileira a marcacao de uma ou mais mensagens como lidas (read receipt).
        messageIds lista os IDs a marcar e fromMe indica se foram enviadas pela
        propria instancia. O envio e assincrono (resposta 202).
      operationId: markMessagesRead
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MarkReadRequest'
            example:
              to: '5511999999999'
              messageIds:
                - 3EB0XXXXXXXXXXXXXXXX
              fromMe: false
      responses:
        '202':
          description: Marcacao enfileirada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueuedTextResponse'
              example:
                queued: true
                to: '5511999999999'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/conversations:
    get:
      tags: [Inbox]
      summary: Lista as conversas (historico persistido)
      description: >-
        Lista as conversas do projeto (uma por contato/numero), com resumo da
        ultima mensagem e contagem de nao-lidas. O historico e PERSISTIDO pela
        nossa infra e sobrevive a deletar/reconectar a instancia — voce nao
        precisa de banco proprio. Paginado (30 por pagina).
      operationId: listInboxConversations
      parameters:
        - name: status
          in: query
          required: false
          description: Filtra por status. Vazio = todas.
          schema:
            type: string
            enum: ['', open, pending, resolved]
        - name: page
          in: query
          required: false
          description: >-
            Numero da pagina (1-based; padrao 1). Cada pagina traz no maximo 30
            conversas, ordenadas pela ultima mensagem (mais recentes primeiro).
            Valores menores que 1 sao tratados como 1. Os contadores em `counts`
            sao do projeto inteiro e nao mudam com a paginacao nem com o filtro
            de `status`.
          schema:
            type: integer
            default: 1
            minimum: 1
      responses:
        '200':
          description: Conversas + contadores por status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ConversationSummary'
                  page:
                    type: integer
                  counts:
                    $ref: '#/components/schemas/ConversationCounts'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/inbox/conversations/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    get:
      tags: [Inbox]
      summary: Detalha uma conversa
      description: >-
        Retorna os dados de uma unica conversa (resumo do contato, status,
        atribuicao, contagem de nao-lidas e resumo da ultima mensagem), igual ao
        item devolvido por `listInboxConversations`. Use para recarregar o
        cabecalho de uma conversa ja conhecida pelo `id`. A conversa e resolvida
        no escopo do projeto (tenant): um `id` de outro projeto retorna 404. Para
        o corpo das mensagens, use `GET /v1/inbox/conversations/{id}/messages`.
      operationId: getInboxConversation
      responses:
        '200':
          description: Conversa.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConversationSummary'
              example:
                id: conv_01HV9Z3K8QABCDEF
                instanceId: inst_01HV9Z3K8Q1234567
                protocol: '2026-000123'
                contactId: ct_01HV9Z3K8QXYZ
                contactName: Maria Silva
                contactPhone: '5511999990000'
                contactJid: '5511999990000@s.whatsapp.net'
                status: open
                assignedUserId: null
                unread: 2
                lastMessageAt: '2026-06-06T13:45:12Z'
                lastMessageText: Bom dia, ainda esta disponivel?
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/conversations/{id}/messages:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    get:
      tags: [Inbox]
      summary: Lista as mensagens de uma conversa
      description: >-
        Devolve as mensagens da conversa em ordem cronologica (mais antiga →
        mais nova), em PAGINAS. Por padrao traz as `limit` mais recentes. Para o
        historico completo (scrollback), pagine para tras com `before`: passe o
        `nextBefore` da resposta para buscar as mensagens ANTERIORES, repetindo
        ate `hasMore=false`. Mensagens de midia trazem uma `mediaUrl`
        PRE-ASSINADA gerada na leitura (validade ~6h); o binario fica no nosso
        storage por 2 ANOS (depois e removido — o texto permanece), entao basta
        reler para obter uma URL nova; se precisar da midia por mais tempo, baixe
        e armazene do seu lado.
      operationId: listInboxMessages
      parameters:
        - name: limit
          in: query
          required: false
          description: Quantas mensagens trazer por pagina (padrao 50, max 200).
          schema:
            type: integer
            default: 50
            maximum: 200
        - name: before
          in: query
          required: false
          description: >-
            Cursor de scrollback: id de uma mensagem. Devolve as mensagens
            ANTERIORES a ela. Use o `nextBefore` da resposta anterior. Vazio =
            pagina mais recente.
          schema:
            type: string
      responses:
        '200':
          description: Pagina de mensagens (ordem cronologica) + cursor de scrollback.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/InboxMessage'
                  hasMore:
                    type: boolean
                    description: Ha mensagens mais antigas alem desta pagina.
                  nextBefore:
                    type: [string, 'null']
                    description: >-
                      Cursor para a proxima pagina (mais antigas). Passe em
                      `before`. null quando hasMore=false.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/conversations/{id}/history:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    post:
      tags: [Inbox]
      summary: Puxa o historico anterior de uma conversa
      description: >-
        Dispara, de forma ASSINCRONA, a importacao do historico ANTERIOR de uma
        conversa: a plataforma pega a mensagem mais antiga que ja tem (a ancora)
        e pede ao WhatsApp o lote imediatamente anterior (~50 mensagens). As
        mensagens chegam depois e populam o Inbox — releia com
        GET /v1/inbox/conversations/{id}/messages (paginando com `before`) para
        ve-las. Util quando a instancia foi conectada SEM importar historico
        (ver a configuracao importHistory em
        PATCH /v1/instances/{id}/settings) e voce quer trazer conversas
        anteriores sob demanda. Requer a instancia CONECTADA. Limite do WhatsApp:
        ele entrega uma janela recente, nao e garantido "tudo de sempre".
      operationId: requestConversationHistory
      responses:
        '202':
          description: Pedido aceito. As mensagens chegam de forma assincrona.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requested:
                    type: boolean
              example:
                requested: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/webhooks:
    get:
      tags: [Webhooks]
      summary: Lista seus webhooks
      description: >-
        Lista todos os webhooks cadastrados no Projeto da API key, com o estado
        atual de cada um: eventos assinados, se esta habilitado e os marcadores
        de saude da entrega (lastSuccessAt, lastFailureAt e lastFailureReason)
        que o dispatcher atualiza a cada tentativa. O `secret` NUNCA e retornado
        aqui — ele so aparece uma unica vez, na criacao. Escopado ao seu Projeto
        (modo test/live conforme o host/ambiente da API key).
      operationId: listWebhooks
      responses:
        '200':
          description: Webhooks do Projeto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Webhook'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Webhooks]
      summary: Cadastra um webhook (self-service)
      description: >-
        Cria um webhook no seu Projeto. A resposta inclui o `secret` (whsec_)
        UMA unica vez — guarde para validar a assinatura X-Pepino-Signature de
        cada entrega. Perdeu o secret? Delete e crie outro.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
            example:
              name: meu-endpoint
              url: https://meusistema.com/webhook/pepinozap
              events: []
      responses:
        '201':
          description: Webhook criado (com o secret, mostrado uma vez).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateWebhookResponse'
              example:
                id: cm5x8wh0001abc123def456
                name: meu-endpoint
                url: https://meusistema.com/webhook/pepinozap
                events: []
                enabled: true
                lastSuccessAt: null
                lastFailureAt: null
                lastFailureReason: null
                createdAt: '2026-06-05T12:00:00Z'
                secret: whsec_3a1f9c2b7e4d6a8f0b2c4d6e8f1a3b5c
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do webhook.
        schema:
          type: string
    patch:
      tags: [Webhooks]
      summary: Atualiza um webhook (pausar/retomar a entrega)
      description: >-
        Atualizacao parcial do webhook. Hoje o unico campo editavel e `enabled`
        (true/false), que liga ou desliga a ENTREGA sem perder a configuracao —
        URL, eventos e secret sao preservados (diferente de remover). Para trocar
        URL ou eventos, remova e crie outro. O `secret` nao e retornado aqui (so
        aparece uma vez, na criacao). Escopado ao Projeto e ao modo (test/live)
        da credencial; um webhook de outro modo retorna 404.
      operationId: updateWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateWebhookRequest'
            example:
              enabled: false
      responses:
        '200':
          description: Webhook atualizado (estado atual, sem o secret).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
              example:
                id: cm5x8wh0001abc123def456
                name: meu-endpoint
                url: https://meusistema.com/webhook/pepinozap
                events: []
                enabled: false
                lastSuccessAt: '2026-06-08T13:42:07Z'
                lastFailureAt: null
                lastFailureReason: null
                createdAt: '2026-06-05T12:00:00Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Webhooks]
      summary: Remove um webhook
      description: >-
        Remove permanentemente o webhook indicado por `id` no Projeto da API
        key. A partir da remocao o endpoint deixa de receber qualquer evento e o
        `secret` associado e descartado. Responde 204 sem corpo em caso de
        sucesso e 404 se o webhook nao existir ou nao pertencer a este Projeto.
        Para trocar a URL ou os eventos de um webhook, remova-o e crie outro
        (nao ha endpoint de atualizacao); um novo `secret` sera gerado.
      operationId: deleteWebhook
      responses:
        '204':
          description: Removido.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/reports/summary:
    get:
      tags: [Relatorios]
      summary: Relatorio de gestao (cards + series)
      description: >-
        Devolve as metricas agregadas do atendimento numa janela de tempo: os
        cards do dashboard (total de conversas, receptivas x ativas, pendentes,
        abertas, resolvidas, novos contatos, total de atendentes), o tempo de
        primeira resposta (media, mediana e tamanho da amostra), a produtividade
        por atendente e a serie de conversas abertas por dia. As metricas saem
        PRONTAS — voce nao reimplementa heuristica. receptiva x ativa vem da
        direcao de abertura (quem mandou a 1a mensagem); o tempo de primeira
        resposta usa as ancoras gravadas na conversa. A janela e em datas no
        fuso de Brasilia (UTC-3); padrao = ultimos 30 dias.
      operationId: getReportsSummary
      parameters:
        - name: from
          in: query
          required: false
          description: >-
            Inicio da janela, data YYYY-MM-DD no fuso de Brasilia (inclusivo).
            Omitido = 29 dias antes de `to`.
          schema:
            type: string
            format: date
            example: '2026-05-08'
        - name: to
          in: query
          required: false
          description: >-
            Fim da janela, data YYYY-MM-DD no fuso de Brasilia (inclui o dia
            inteiro). Omitido = hoje. Janela maxima de 366 dias.
          schema:
            type: string
            format: date
            example: '2026-06-06'
        - name: instanceId
          in: query
          required: false
          description: Filtra por uma instancia (numero). Vazio = todas.
          schema:
            type: string
        - name: assignedUserId
          in: query
          required: false
          description: Filtra pela conversa atribuida a um atendente. Vazio = todos.
          schema:
            type: string
      responses:
        '200':
          description: Metricas agregadas da janela.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReportSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/inbox/conversations/{id}/status:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    post:
      tags: [Inbox]
      summary: Muda o status (encerrar/reabrir) da conversa
      description: >-
        Define o status da conversa: `open`, `pending` ou `resolved`. Ao
        RESOLVER (`resolved`) voce pode anexar a justificativa de encerramento:
        `closeReasonId` (um motivo do catalogo do Projeto — veja Justificativas)
        e/ou `closeNotes` (observacao livre). Reabrir (`open`/`pending`) limpa a
        justificativa anterior. O `closeReasonId` precisa existir no Projeto;
        invalido retorna 400.
      operationId: setConversationStatus
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetConversationStatusRequest'
            example:
              status: resolved
              closeReasonId: cr_01HV9Z3K8Q
              closeNotes: Cliente resolveu pelo app.
      responses:
        '204':
          description: Status atualizado.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/conversations/{id}/assign:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    post:
      tags: [Inbox]
      summary: Atribui (ou desatribui) a conversa a um atendente
      description: >-
        Atribui a conversa a um atendente do Projeto pelo `userId`, ou
        desatribui passando `userId: null`. O atendente-alvo precisa pertencer
        ao mesmo Projeto; um id de outro Projeto (ou inexistente) retorna 400.
        Atribuir manualmente convive com o roteamento automatico configurado na
        Equipe (uma atribuicao manual fixa o dono daquela conversa).
      operationId: assignConversation
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssignConversationRequest'
            example:
              userId: usr_01HV9Z3K8Q
      responses:
        '204':
          description: Conversa atribuida/desatribuida (sem corpo).
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/conversations/{id}/read:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    post:
      tags: [Inbox]
      summary: Marca a conversa como lida
      description: >-
        Zera o contador de mensagens nao lidas (unread) da conversa, marcando-a
        como lida. Nao recebe corpo. Escopada ao Projeto da credencial; responde
        204 sem corpo. Observacao: por eficiencia o handler nao verifica
        existencia antes de marcar — um id que nao pertence ao Projeto apenas nao
        afeta nada e ainda assim retorna 204.
      operationId: markConversationRead
      responses:
        '204':
          description: Conversa marcada como lida.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/inbox/conversations/{id}/contact:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    patch:
      tags: [Inbox]
      summary: Edita o contato da conversa
      description: >-
        Atualiza os dados do contato vinculado a conversa — na implementacao
        atual, renomeia o contato (edicao manual do atendente). `name` e
        obrigatorio (trim aplicado; nao pode ficar vazio). A conversa precisa
        pertencer ao Projeto (senao 404). Como o nome incide sobre o contato (que
        pode aparecer em mais de uma conversa), a mudanca reflete em todas as
        conversas daquele contato. Responde 204 sem corpo.
      operationId: updateConversationContact
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateContactNameRequest'
            example:
              name: Joao da Silva
      responses:
        '204':
          description: Contato atualizado.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/labels:
    get:
      tags: [Etiquetas]
      summary: Lista as etiquetas do Projeto
      description: >-
        Lista a paleta de etiquetas ativas do Projeto (id, nome e cor). As
        etiquetas sao usadas para classificar conversas e filtrar o Inbox
        (parametro `labelId` em listInboxConversations).
      operationId: listLabels
      responses:
        '200':
          description: Etiquetas do Projeto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Label'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Etiquetas]
      summary: Cria uma etiqueta
      description: >-
        Cria uma etiqueta no Projeto. `name` e obrigatorio (max. 40
        caracteres, unico no Projeto). `color` e uma das cores da paleta fixa
        (gray, green, blue, purple, amber, red, cyan, pink); outra cor cai para
        `gray`. Nome repetido retorna 409.
      operationId: createLabel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateLabelRequest'
            example:
              name: VIP
              color: amber
      responses:
        '201':
          description: Etiqueta criada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Label'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/inbox/labels/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da etiqueta.
        schema:
          type: string
    patch:
      tags: [Etiquetas]
      summary: Atualiza uma etiqueta
      description: >-
        Atualiza nome e/ou cor de uma etiqueta. Campos omitidos ficam como
        estao. Nome repetido retorna 409.
      operationId: updateLabel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateLabelRequest'
            example:
              color: red
      responses:
        '204':
          description: Etiqueta atualizada.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      tags: [Etiquetas]
      summary: Remove uma etiqueta
      description: >-
        Remove (desativa) a etiqueta. E um soft-delete: a etiqueta some da
        paleta e das conversas, mas o historico que ja a referenciava nao
        quebra. Responde 204.
      operationId: deleteLabel
      responses:
        '204':
          description: Etiqueta removida.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/inbox/conversations/{id}/labels/{labelId}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
      - name: labelId
        in: path
        required: true
        description: Identificador da etiqueta.
        schema:
          type: string
    put:
      tags: [Etiquetas]
      summary: Aplica uma etiqueta a conversa
      description: >-
        Vincula a etiqueta a conversa (relacao M:N). Idempotente: aplicar a
        mesma etiqueta de novo nao gera duplicata. A etiqueta precisa pertencer
        ao Projeto. Responde 204.
      operationId: assignConversationLabel
      responses:
        '204':
          description: Etiqueta aplicada.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Etiquetas]
      summary: Remove a etiqueta da conversa
      description: Desvincula a etiqueta da conversa. Responde 204.
      operationId: unassignConversationLabel
      responses:
        '204':
          description: Etiqueta removida da conversa.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/departments:
    get:
      tags: [Departamentos]
      summary: Lista os departamentos do Projeto
      description: >-
        Lista os departamentos ativos do Projeto (id, nome, descricao). Os
        departamentos organizam a equipe e classificam as conversas; uma conversa
        pode ser transferida para um departamento (POST
        /v1/inbox/conversations/{id}/department).
      operationId: listDepartments
      responses:
        '200':
          description: Departamentos do Projeto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Department'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Departamentos]
      summary: Cria um departamento
      description: >-
        Cria um departamento no Projeto. `name` e obrigatorio (max. 50
        caracteres, unico no Projeto). `description` e opcional. Nome repetido
        retorna 409.
      operationId: createDepartment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDepartmentRequest'
            example:
              name: Financeiro
              description: Cobranca, boletos e notas fiscais
      responses:
        '201':
          description: Departamento criado.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Department'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/inbox/departments/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do departamento.
        schema:
          type: string
    patch:
      tags: [Departamentos]
      summary: Atualiza um departamento
      description: >-
        Atualiza nome e/ou descricao de um departamento. Campos omitidos ficam
        como estao. Nome repetido retorna 409.
      operationId: updateDepartment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateDepartmentRequest'
            example:
              description: Suporte tecnico nivel 1
      responses:
        '204':
          description: Departamento atualizado.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      tags: [Departamentos]
      summary: Remove um departamento
      description: >-
        Remove (desativa) o departamento. E um soft-delete: o departamento some
        do catalogo; as conversas que estavam nele voltam para a fila geral e os
        atendentes/numeros vinculados perdem o vinculo. Responde 204.
      operationId: deleteDepartment
      responses:
        '204':
          description: Departamento removido.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/inbox/conversations/{id}/department:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da conversa.
        schema:
          type: string
    post:
      tags: [Departamentos]
      summary: Transfere a conversa para um departamento
      description: >-
        Move a conversa para o departamento informado e SOLTA o atendente atual
        (a conversa volta para a fila do novo departamento, sem dono). Envie
        `departmentId: null` para mandar para a fila geral (sem departamento,
        visivel a todos). O departamento precisa pertencer ao Projeto. Responde
        204.
      operationId: transferConversationDepartment
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransferDepartmentRequest'
            example:
              departmentId: dep_01HXYZ
      responses:
        '204':
          description: Conversa transferida.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/inbox/close-reasons:
    get:
      tags: [Justificativas]
      summary: Lista as justificativas de encerramento
      description: >-
        Lista os motivos de encerramento ativos do Projeto (id, nome, posicao).
        O atendente escolhe um deles ao resolver uma conversa.
      operationId: listCloseReasons
      responses:
        '200':
          description: Justificativas do Projeto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/CloseReason'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Justificativas]
      summary: Cria uma justificativa de encerramento
      description: >-
        Cria um motivo de encerramento. `name` e obrigatorio (max. 60
        caracteres, unico no Projeto). Nome repetido retorna 409.
      operationId: createCloseReason
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCloseReasonRequest'
            example:
              name: Resolvido
      responses:
        '201':
          description: Justificativa criada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CloseReason'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/inbox/close-reasons/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da justificativa.
        schema:
          type: string
    delete:
      tags: [Justificativas]
      summary: Remove uma justificativa de encerramento
      description: >-
        Remove (desativa) o motivo. Soft-delete: some do catalogo, mas as
        conversas ja encerradas com ele mantem o registro. Responde 204.
      operationId: deleteCloseReason
      responses:
        '204':
          description: Justificativa removida.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/flows:
    get:
      tags: [Fluxos]
      summary: Lista os fluxos
      description: >-
        Lista os fluxos de chatbot do Projeto (no modo da API key). Cada item
        traz a instancia vinculada, o gatilho e se esta ativo. So 1 fluxo pode
        ficar ATIVO por instancia.
      operationId: listFlows
      responses:
        '200':
          description: Fluxos do Projeto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Flow'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Fluxos]
      summary: Cria um fluxo
      description: >-
        Cria um fluxo (cabecalho, sem nos ainda). `name` e obrigatorio.
        `instanceId` (opcional) vincula o fluxo a uma instancia do Projeto;
        instancia de outro Projeto/modo retorna 400. `triggerType` e `any`
        (qualquer mensagem inicia) ou `keyword` (so quando `triggerValue`
        aparece no texto recebido); valor diferente vira `any`. O fluxo nasce
        INATIVO — adicione os nos e ative com PATCH.
      operationId: createFlow
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFlowRequest'
            example:
              name: Atendimento inicial
              instanceId: inst_01HV9Z3K8Q
              triggerType: any
      responses:
        '201':
          description: Fluxo criado (id).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/flows/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do fluxo.
        schema:
          type: string
    get:
      tags: [Fluxos]
      summary: Detalha um fluxo (com os nos)
      description: >-
        Retorna o fluxo e a lista de nos (com posicao no canvas, conteudo e a
        ligacao `proximoNodeId`). E o que o editor visual carrega para desenhar
        o diagrama.
      operationId: getFlow
      responses:
        '200':
          description: Fluxo + nos.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FlowWithNodes'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Fluxos]
      summary: Atualiza um fluxo (incl. ativar/desativar)
      description: >-
        Atualiza cabecalho do fluxo. Campos omitidos ficam como estao. Passar
        `active: true` ATIVA o fluxo e desativa automaticamente os outros da
        mesma instancia (so 1 ativo por numero) — conflito de unicidade retorna
        409. `active: false` desativa.
      operationId: updateFlow
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateFlowRequest'
            example:
              active: true
      responses:
        '204':
          description: Fluxo atualizado.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      tags: [Fluxos]
      summary: Remove um fluxo
      description: >-
        Remove o fluxo, seus nos e encerra as sessoes (bots) que estiverem
        rodando nele. Responde 204.
      operationId: deleteFlow
      responses:
        '204':
          description: Fluxo removido.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/flows/{id}/nodes:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do fluxo.
        schema:
          type: string
    post:
      tags: [Fluxos]
      summary: Cria um no no fluxo
      description: >-
        Adiciona um no ao fluxo. `tipo` e um de message, menu, input, condition,
        action, delay, transfer, close. `conteudo` e um objeto JSON cujo formato
        depende do tipo (veja o guia do construtor de fluxo); `posicaoX`/
        `posicaoY` sao a posicao no canvas e `ordem` define o no inicial (menor
        ordem = primeiro). `conteudo` e limitado a 16 KB.
      operationId: createFlowNode
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFlowNodeRequest'
            example:
              tipo: message
              nome: Saudacao
              conteudo:
                texto: 'Ola {{nome}}, tudo bem?'
              posicaoX: 120
              posicaoY: 80
              ordem: 0
      responses:
        '201':
          description: No criado (id).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/flows/{id}/nodes/{nodeId}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do fluxo.
        schema:
          type: string
      - name: nodeId
        in: path
        required: true
        description: Identificador do no.
        schema:
          type: string
    patch:
      tags: [Fluxos]
      summary: Atualiza um no
      description: >-
        Atualiza nome, conteudo e/ou posicao de um no. Campos omitidos ficam
        como estao (o editor visual usa este endpoint tanto para salvar o
        conteudo quanto para persistir o arraste no canvas).
      operationId: updateFlowNode
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateFlowNodeRequest'
            example:
              conteudo:
                texto: 'Novo texto'
      responses:
        '204':
          description: No atualizado.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Fluxos]
      summary: Remove um no
      description: Remove o no do fluxo. Responde 204.
      operationId: deleteFlowNode
      responses:
        '204':
          description: No removido.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/flows/{id}/nodes/{nodeId}/connection:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do fluxo.
        schema:
          type: string
      - name: nodeId
        in: path
        required: true
        description: Identificador do no de origem.
        schema:
          type: string
    patch:
      tags: [Fluxos]
      summary: Liga o no ao proximo (aresta padrao)
      description: >-
        Define a saida padrao do no: para qual no o fluxo segue depois deste.
        `proximoNodeId: null` desconecta. E a aresta que liga os nos lineares
        (message/input/delay/action). Menus usam a ligacao por opcao.
      operationId: setFlowNodeConnection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetNodeConnectionRequest'
            example:
              proximoNodeId: nd_01HV9Z3K8Q
      responses:
        '204':
          description: Ligacao atualizada.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/flows/{id}/nodes/{nodeId}/option-connection:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do fluxo.
        schema:
          type: string
      - name: nodeId
        in: path
        required: true
        description: Identificador do no de menu.
        schema:
          type: string
    patch:
      tags: [Fluxos]
      summary: Liga uma opcao de menu a um no
      description: >-
        Para um no do tipo `menu`, define para qual no leva uma OPCAO
        especifica (pelo `optionId`, que vive em conteudo.options[]).
        `targetNodeId: null` desliga aquela opcao. Opcao inexistente retorna
        404.
      operationId: setFlowNodeOptionConnection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetNodeOptionConnectionRequest'
            example:
              optionId: opt_1
              targetNodeId: nd_01HV9Z3K8Q
      responses:
        '204':
          description: Ligacao da opcao atualizada.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/funnels:
    get:
      tags: [Funil]
      summary: Lista os funis
      description: Lista os funis do Projeto (no modo da API key).
      operationId: listFunnels
      responses:
        '200':
          description: Funis do Projeto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Funnel'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Funil]
      summary: Cria um funil
      description: >-
        Cria um funil (sem etapas ainda). `name` e obrigatorio. `instanceId`
        (opcional) limita o funil as conversas de uma instancia; vazio = todas.
      operationId: createFunnel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFunnelRequest'
            example:
              name: Funil de vendas
              instanceId: inst_01HV9Z3K8Q
      responses:
        '201':
          description: Funil criado (id).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
  /v1/funnels/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do funil.
        schema:
          type: string
    get:
      tags: [Funil]
      summary: Detalha um funil (com as etapas)
      description: >-
        Retorna o funil e suas etapas (nome, cor, regra, posicao no canvas e a
        ligacao visual `proximoStageId`). E o que o editor visual carrega. Para
        a contagem/conversas por etapa, use GET /v1/funnels/{id}/data.
      operationId: getFunnel
      responses:
        '200':
          description: Funil + etapas.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FunnelWithStages'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Funil]
      summary: Atualiza um funil
      description: Atualiza nome e/ou instancia de escopo. Campos omitidos ficam como estao.
      operationId: updateFunnel
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateFunnelRequest'
            example:
              name: Funil principal
      responses:
        '204':
          description: Funil atualizado.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Funil]
      summary: Remove um funil
      description: >-
        Remove o funil e suas etapas. As conversas NAO sao afetadas (o funil so
        as classifica). Responde 204.
      operationId: deleteFunnel
      responses:
        '204':
          description: Funil removido.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/funnels/{id}/data:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do funil.
        schema:
          type: string
    get:
      tags: [Funil]
      summary: Dados do funil (contagem + conversas por etapa)
      description: >-
        Avalia o funil em tempo de leitura: para cada conversa no escopo, acha a
        etapa de MAIOR posicao cuja regra ela satisfaz e a coloca ali. Devolve,
        por etapa, a contagem e ate 50 conversas (as mais recentes), alem de
        `unplaced` (conversas que nao casaram com nenhuma etapa) e
        `totalEvaluated`. Avaliacao limitada as 5000 conversas mais recentes.
      operationId: getFunnelData
      responses:
        '200':
          description: Funil com contagem e amostra de conversas por etapa.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FunnelData'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/funnels/{id}/stages:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do funil.
        schema:
          type: string
    post:
      tags: [Funil]
      summary: Cria uma etapa no funil
      description: >-
        Adiciona uma etapa. `name` e obrigatorio; `rule` define quando uma
        conversa entra nesta etapa (veja StageRule e o guia do funil).
        `posicaoX`/`posicaoY` sao a posicao no canvas; `position` a ordem
        (maior = mais avancada no funil).
      operationId: createFunnelStage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateFunnelStageRequest'
            example:
              name: Proposta enviada
              color: amber
              rule:
                status: open
                labelId: lbl_01HV9Z3K8Q
              posicaoX: 320
              posicaoY: 120
              position: 2
      responses:
        '201':
          description: Etapa criada (id).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/funnels/{id}/stages/{stageId}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do funil.
        schema:
          type: string
      - name: stageId
        in: path
        required: true
        description: Identificador da etapa.
        schema:
          type: string
    patch:
      tags: [Funil]
      summary: Atualiza uma etapa
      description: >-
        Atualiza nome, cor, regra, posicao no canvas e/ou ordem. Campos omitidos
        ficam como estao (o editor usa este endpoint tanto para salvar a regra
        quanto para persistir o arraste no canvas).
      operationId: updateFunnelStage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateFunnelStageRequest'
            example:
              rule:
                origin: inbound
                flowMode: at_node
                flowId: flw_01HV9Z3K8Q
                nodeId: nd_01HV9Z3K8Q
      responses:
        '204':
          description: Etapa atualizada.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Funil]
      summary: Remove uma etapa
      description: Remove a etapa do funil. Responde 204.
      operationId: deleteFunnelStage
      responses:
        '204':
          description: Etapa removida.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/funnels/{id}/stages/{stageId}/connection:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador do funil.
        schema:
          type: string
      - name: stageId
        in: path
        required: true
        description: Identificador da etapa de origem.
        schema:
          type: string
    patch:
      tags: [Funil]
      summary: Liga uma etapa a proxima (aresta visual)
      description: >-
        Define a ligacao VISUAL entre etapas no canvas (`proximoStageId`).
        `null` desconecta. E apenas cosmetica — nao altera a regra nem o
        placement das conversas.
      operationId: setFunnelStageConnection
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetStageConnectionRequest'
            example:
              proximoStageId: stg_01HV9Z3K8Q
      responses:
        '204':
          description: Ligacao atualizada.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/campaigns:
    get:
      tags: [Campanhas]
      summary: Lista as campanhas do projeto
      description: >-
        Lista as campanhas de disparo em massa do Projeto, com os contadores de
        progresso (total de destinatarios, enviados e falhos) e os marcos de
        tempo. Apenas leitura — segura para polling de status e LIBERADA sem
        add-on/assinatura (o gate comercial incide so em criar/disparar). Retorna
        ate 100 itens (sem paginacao).
      operationId: listCampaigns
      responses:
        '200':
          description: Campanhas do Projeto.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Campaign'
              example:
                items:
                  - id: camp_3Hk9Zq2pVxL
                    name: Promo Junho
                    status: COMPLETED
                    pausedReason: null
                    totalRecipients: 1500
                    sentCount: 1487
                    failedCount: 13
                    startedAt: '2026-06-07T14:02:11Z'
                    completedAt: '2026-06-07T16:09:55Z'
                    createdAt: '2026-06-07T13:55:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Campanhas]
      summary: Cria uma campanha (rascunho)
      description: >-
        Cria uma campanha de disparo em massa em rascunho (DRAFT) — nada e
        enviado aqui (o disparo ocorre em POST /v1/campaigns/{id}/start). A
        mensagem e um template com variaveis por destinatario ({{nome}} e o que
        vier em vars). Cada destinatario pode ser so o numero (string) ou um
        objeto com phone, name e vars. Numeros sao normalizados para digitos, com
        vazios e duplicados descartados (totalRecipients pode ser menor que a
        lista); limite de 10.000 por campanha. A cadencia anti-ban e opcional —
        campos omissos usam defaults sensatos. GATING: criar exige que a CONTA
        tenha o add-on "Disparo em massa" — ter API key valida NAO basta; sem o
        add-on a API retorna 402 em producao (no modo teste e liberado).
      operationId: createCampaign
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCampaignRequest'
            example:
              name: Promo Junho
              instanceId: cm5x8a9k20001abc123def456
              template: 'Ola {{nome}}! Aproveite {{desconto}} so hoje. Responda PARAR para sair.'
              recipients:
                - phone: '+55 11 99999-0001'
                  name: Maria
                  vars: { desconto: '20% off' }
                - '5511999990002'
              cadence:
                warmup: { base: 20, factor: 1.2, cap: 200 }
                window: { start: '08:00', end: '23:00', tz: America/Sao_Paulo }
                pacing: { baseSeconds: 60, jitterPct: 0.2 }
                autoPause: { consecutiveThreshold: 5 }
                validateNumbers: true
      responses:
        '201':
          description: Campanha criada em rascunho.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateCampaignResponse'
              example:
                id: camp_8Tn4Wb1cMrA
                totalRecipients: 2
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/campaigns/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da campanha.
        schema:
          type: string
    get:
      tags: [Campanhas]
      summary: Consulta uma campanha (status agregado + cadencia)
      description: >-
        Retorna os detalhes da campanha: status, totais por status do
        destinatario (pending/sent/delivered/read/responded/failed/invalid), a
        configuracao de cadencia e o estado de runtime (dia da campanha, teto do
        dia, enviados no dia, ultimo e proximo envio). Recomendado para
        acompanhar o andamento. Apenas leitura, sem gate comercial.
      operationId: getCampaign
      responses:
        '200':
          description: Detalhes da campanha.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Campanhas]
      summary: Edita uma campanha (rascunho ou pausada)
      description: >-
        Edita nome, mensagem-template e/ou cadencia. So vale enquanto a campanha
        esta em DRAFT ou PAUSED (409 caso contrario). Campos omitidos ficam como
        estao. Exige o add-on "Disparo em massa".
      operationId: updateCampaign
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCampaignRequest'
            example:
              template: 'Oi {{nome}}, novidade pra voce!'
              cadence:
                pacing: { baseSeconds: 90, jitterPct: 0.25 }
      responses:
        '200':
          description: Campanha atualizada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/campaigns/{id}/start:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da campanha a disparar (precisa estar em DRAFT ou SCHEDULED).
        schema:
          type: string
    post:
      tags: [Campanhas]
      summary: Dispara (inicia) uma campanha
      description: >-
        Inicia o disparo de uma campanha em rascunho (DRAFT) ou agendada
        (SCHEDULED). ASSINCRONO: responde 202 imediatamente; a engine de cadencia
        passa a enviar respeitando warmup, janela horaria, ritmo com jitter,
        auto-pausa e validacao de numero. Acompanhe via GET /v1/campaigns/{id} e
        pelo evento campaign.status. GATING DUPLO: disparar exige a CONTA com
        assinatura ATIVA E o add-on "Disparo em massa" (402 sem isso, em
        producao; no modo teste e liberado). AVISO CRITICO: disparo em massa pode
        resultar em BANIMENTO do numero pela Meta; use listas com opt-in,
        mensagens nao-spam e volume moderado — o risco e do titular da conta.
      operationId: startCampaign
      responses:
        '202':
          description: Disparo aceito; a engine de cadencia assume em background.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StartCampaignResponse'
              example:
                started: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/campaigns/{id}/pause:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da campanha (precisa estar RUNNING).
        schema:
          type: string
    post:
      tags: [Campanhas]
      summary: Pausa uma campanha
      description: >-
        Pausa manualmente uma campanha em andamento (RUNNING → PAUSED). Os
        disparos param ate retomar; o que ja saiu continua sendo rastreado.
      operationId: pauseCampaign
      responses:
        '200':
          description: Campanha pausada (estado atualizado).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/campaigns/{id}/resume:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da campanha (precisa estar PAUSED).
        schema:
          type: string
    post:
      tags: [Campanhas]
      summary: Retoma uma campanha pausada
      description: >-
        Retoma uma campanha pausada (PAUSED → RUNNING), inclusive apos auto-pausa
        por falhas. Zera o contador de falhas consecutivas. Exige assinatura
        ativa + add-on (igual a disparar).
      operationId: resumeCampaign
      responses:
        '200':
          description: Campanha retomada (estado atualizado).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/campaigns/{id}/complete:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da campanha a encerrar.
        schema:
          type: string
      - name: cancel
        in: query
        required: false
        description: Se "true", marca como CANCELLED em vez de COMPLETED.
        schema:
          type: boolean
    post:
      tags: [Campanhas]
      summary: Encerra (ou cancela) uma campanha
      description: >-
        Encerra a campanha (status COMPLETED) parando os disparos pendentes. Com
        ?cancel=true marca como CANCELLED. Acao final — o que ja foi enviado
        permanece no historico.
      operationId: completeCampaign
      responses:
        '200':
          description: Campanha encerrada (estado atualizado).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CampaignDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/campaigns/{id}/recipients:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da campanha.
        schema:
          type: string
    get:
      tags: [Campanhas]
      summary: Lista destinatarios (status por destinatario)
      description: >-
        Lista os destinatarios da campanha com o status individual
        (pending/sending/sent/delivered/read/responded/failed/invalid), o motivo
        de erro quando houver e os marcos de tempo. Paginado (50 por pagina);
        filtre por status com ?status=. Apenas leitura.
      operationId: listCampaignRecipients
      parameters:
        - name: page
          in: query
          required: false
          description: Pagina (1-based). Default 1.
          schema:
            type: integer
        - name: status
          in: query
          required: false
          description: >-
            Filtra por status (maiusculo):
            PENDING|SENDING|SENT|DELIVERED|READ|RESPONDED|FAILED|INVALID.
          schema:
            type: string
      responses:
        '200':
          description: Pagina de destinatarios.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/CampaignRecipient'
                  page:
                    type: integer
                  total:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Campanhas]
      summary: Adiciona destinatarios
      description: >-
        Acrescenta destinatarios a uma campanha (DRAFT, SCHEDULED, RUNNING ou
        PAUSED — 409 se ja encerrada). Mesmo formato do create (string ou objeto).
        Respeita o limite de 10.000 no total. Numa campanha RUNNING os novos
        entram na fila e seguem a cadencia. Exige o add-on.
      operationId: addCampaignRecipients
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddRecipientsRequest'
            example:
              recipients:
                - phone: '5511999990010'
                  name: Joao
                - '5511999990011'
      responses:
        '201':
          description: Destinatarios adicionados.
          content:
            application/json:
              schema:
                type: object
                properties:
                  added:
                    type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
  /v1/campaigns/{id}/recipients/{sendId}:
    parameters:
      - name: id
        in: path
        required: true
        description: Identificador da campanha.
        schema:
          type: string
      - name: sendId
        in: path
        required: true
        description: Identificador do destinatario (id retornado em GET .../recipients).
        schema:
          type: string
    delete:
      tags: [Campanhas]
      summary: Remove um destinatario (ainda nao disparado)
      description: >-
        Remove um destinatario da campanha. So funciona se ele ainda estiver
        PENDING (404 se ja foi disparado ou nao existe).
      operationId: removeCampaignRecipient
      responses:
        '200':
          description: Destinatario removido.
          content:
            application/json:
              schema:
                type: object
                properties:
                  removed:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: API key enviada como Authorization Bearer pzk_live_... ou pzk_test_...
  parameters:
    InstanceId:
      name: id
      in: path
      required: true
      description: Identificador da instancia.
      schema:
        type: string
        example: cm5x8a9k20001abc123def456
  schemas:
    InstanceStatus:
      type: string
      description: >-
        Estado atual da instancia no ciclo de vida do pareamento/conexao.
        DISCONNECTED: criada ou desconectada, sem sessao ativa (estado inicial).
        CONNECTING: tentando estabelecer a conexao com o WhatsApp. QR_PENDING:
        aguardando o escaneamento do QR Code para parear. CONNECTED: online e
        apta a enviar e receber mensagens. BANNED: numero banido pelo WhatsApp
        (estado terminal). ERROR: falha irrecuperavel da sessao (estado terminal).
        Em BANNED e ERROR os endpoints de envio sao bloqueados com 409 (instancia
        nao operacional).
      enum:
        - DISCONNECTED
        - CONNECTING
        - QR_PENDING
        - CONNECTED
        - BANNED
        - ERROR
    Instance:
      type: object
      properties:
        id:
          type: string
          description: Identificador unico da instancia, gerado na criacao. Use-o no path dos demais endpoints.
        projectId:
          type: string
          description: Identificador do Projeto ao qual a instancia pertence (o Projeto da API key).
        name:
          type: string
          description: Nome (rotulo) da instancia, definido na criacao. Apenas exibicao; nao precisa ser unico.
        status:
          $ref: '#/components/schemas/InstanceStatus'
        phoneNumber:
          type: [string, 'null']
          description: Numero pareado em E.164, ou null se ainda nao pareada.
        jid:
          type: [string, 'null']
          description: JID do WhatsApp, ou null se ainda nao pareada.
        settings:
          $ref: '#/components/schemas/InstanceSettings'
        createdAt:
          type: string
          format: date-time
          description: Data e hora de criacao da instancia, em ISO 8601 (UTC).
        updatedAt:
          type: string
          format: date-time
          description: Data e hora da ultima atualizacao da instancia (status, settings ou pareamento), em ISO 8601 (UTC).
    CreateInstanceRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: >-
            Nome (rotulo) da instancia. Obrigatorio e nao pode ser vazio. Serve
            apenas para voce identificar o numero no painel/listagem; nao precisa
            ser unico — varias instancias do mesmo Projeto podem ter o mesmo nome.
    InstanceSettings:
      type: object
      description: >-
        Configuracoes de comportamento da instancia. Todos os campos sao
        opcionais e o padrao e false/vazio (comportamento neutro).
      properties:
        rejectCalls:
          type: boolean
          description: Rejeita automaticamente chamadas (voz/video) recebidas.
          default: false
        rejectCallMessage:
          type: string
          description: >-
            Mensagem de texto enviada a quem ligou, logo apos rejeitar a
            chamada. Opcional; so tem efeito com rejectCalls=true.
        ignoreGroups:
          type: boolean
          description: >-
            Ignora mensagens recebidas em grupos (nao dispara webhook nem
            WebSocket nem cria conversa no CRM).
          default: false
        alwaysOnline:
          type: boolean
          description: >-
            Mantem a presenca como "online" (available) enquanto a instancia
            estiver conectada.
          default: false
        readReceipts:
          type: boolean
          description: >-
            Marca automaticamente como lidas as mensagens recebidas (o
            remetente ve o "lido"/tique azul).
          default: false
        importHistory:
          type: boolean
          description: >-
            Ao parear a instancia, importa o historico de conversas que o
            WhatsApp libera no pareamento (persistido, legivel via Inbox).
            Desligado (padrao), comeca do zero e captura so mensagens novas —
            mais leve. Limite do WhatsApp: nao e o historico completo de sempre,
            e a janela recente que ele envia.
          default: false
    UpdateInstanceSettingsRequest:
      type: object
      description: >-
        Corpo do PATCH de configuracoes. PATCH PARCIAL: inclua apenas os campos
        que deseja mudar — os omitidos mantem o valor atual. Todos sao opcionais
        e o padrao de cada um e desligado (false / texto vazio).
      properties:
        rejectCalls:
          type: boolean
          default: false
          description: >-
            Quando true, toda chamada (voz ou video) recebida pelo numero e
            rejeitada automaticamente, na hora, sem tocar. Util para numeros que
            sao so de mensagens. Quando false (padrao), as chamadas tocam
            normalmente. Combine com rejectCallMessage para avisar quem ligou.
        rejectCallMessage:
          type: string
          description: >-
            Texto enviado automaticamente como mensagem para quem ligou, logo
            apos a chamada ser rejeitada. So tem efeito quando rejectCalls=true;
            e ignorado se rejectCalls=false. Deixe vazio (padrao) para rejeitar
            sem enviar nada. Ex.: "No momento nao atendemos chamadas. Envie uma
            mensagem que respondemos por aqui."
        ignoreGroups:
          type: boolean
          default: false
          description: >-
            Quando true, mensagens recebidas em GRUPOS sao ignoradas pela
            plataforma: nao disparam webhook nem WebSocket e nao criam nem
            atualizam conversa no CRM (Inbox). Use para tratar so conversas 1:1.
            Nao afeta mensagens que voce ENVIA para grupos pela API. Quando false
            (padrao), grupos sao processados normalmente.
        alwaysOnline:
          type: boolean
          default: false
          description: >-
            Quando true, a instancia anuncia presenca "online" (disponivel) ao
            conectar e a cada reconexao, mantendo o numero aparecendo como online
            para os contatos enquanto estiver conectado. Quando false (padrao), a
            presenca segue o comportamento normal do WhatsApp. Nao altera a
            entrega de mensagens.
        readReceipts:
          type: boolean
          default: false
          description: >-
            Quando true, cada mensagem recebida e marcada como lida
            automaticamente assim que a plataforma a processa — o remetente ve o
            "lido" (tique azul). Quando false (padrao), as mensagens NAO sao
            marcadas como lidas (sem tique azul), mesmo ja tendo sido recebidas
            via webhook. Ligue para dar confirmacao de leitura automatica.
        importHistory:
          type: boolean
          default: false
          description: >-
            Quando true, ao parear a instancia importa o historico de conversas
            que o WhatsApp libera (persistido, legivel via Inbox). Quando false
            (padrao), comeca do zero e captura so mensagens novas — mais leve. O
            WhatsApp envia uma janela recente, nao "tudo de sempre".
    SendPollMessageRequest:
      type: object
      required: [to, name, options]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
        name:
          type: string
          description: Pergunta (titulo) da enquete.
        options:
          type: array
          description: Alternativas da enquete (de 2 a 12).
          items:
            type: string
          minItems: 2
          maxItems: 12
        selectableCount:
          type: integer
          description: >-
            Numero maximo de opcoes que o destinatario pode marcar. 1 = escolha
            unica; valor maior = multipla escolha. Padrao 1.
          default: 1
    QrTokenResponse:
      type: object
      properties:
        token:
          type: string
          description: Token efemero (2 min, escopo SSE) para abrir o stream de QR.
    SendTextMessageRequest:
      type: object
      required: [to, body]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
          examples:
            - '5511999999999'
            - 5511999999999@s.whatsapp.net
        body:
          type: string
          description: Texto da mensagem.
        quotedId:
          type: string
          description: >-
            Opcional. waMessageId da mensagem a citar (reply). Quando presente,
            o texto e enviado como resposta aquela mensagem.
        quotedText:
          type: string
          description: Opcional. Texto da mensagem citada (preview exibido no reply).
        quotedFromMe:
          type: boolean
          description: >-
            Opcional. true se a mensagem citada foi enviada pela propria
            instancia (define o autor da citacao).
          default: false
    SendMediaMessageRequest:
      type: object
      required: [to, type, mediaUrl]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
        type:
          type: string
          description: Tipo de midia.
          enum: [image, video, audio, document]
        mediaUrl:
          type: string
          format: uri
          description: >-
            URL publica do arquivo a ser enviado. Deve ser http/https acessivel
            publicamente; URLs apontando para rede interna, localhost ou IP
            privado sao rejeitadas com 400 (protecao contra SSRF).
        caption:
          type: string
          description: Legenda opcional.
        filename:
          type: string
          description: Nome do arquivo opcional (usado em document).
        ptt:
          type: boolean
          description: >-
            Apenas para type audio. Quando true, envia o audio como nota de voz
            (voice note / push-to-talk) em vez de arquivo de audio comum.
          default: false
    QueuedTextResponse:
      type: object
      properties:
        queued:
          type: boolean
          description: >-
            Sempre true. Confirma apenas que o envio foi ENFILEIRADO com sucesso,
            nao que a mensagem foi entregue. A confirmacao de entrega/leitura
            chega depois pelo webhook message.status.
        to:
          type: string
          description: >-
            Eco do destino informado na requisicao (campo to), no mesmo formato
            que foi enviado (E.164 apenas com digitos ou JID completo).
    QueuedMediaResponse:
      type: object
      properties:
        queued:
          type: boolean
          description: >-
            Sempre true. Confirma apenas que o envio da midia foi ENFILEIRADO com
            sucesso, nao que foi entregue. O download da mediaUrl (ou o upload do
            arquivo, no media-upload) e o envio ao WhatsApp ocorrem de forma
            assincrona no worker; eventuais falhas de download/envio nao
            aparecem nesta resposta.
        to:
          type: string
          description: >-
            Eco do destino informado na requisicao (campo to), no mesmo formato
            que foi enviado (E.164 apenas com digitos ou JID completo).
        type:
          type: string
          description: >-
            Eco do tipo de midia enfileirado (image, video, audio ou document).
    QueuedStickerResponse:
      type: object
      properties:
        queued:
          type: boolean
          description: >-
            Sempre true. Confirma apenas que o envio do sticker foi ENFILEIRADO
            com sucesso, nao que foi entregue.
        to:
          type: string
          description: >-
            Eco do destino informado na requisicao (campo to), no mesmo formato
            que foi enviado (E.164 apenas com digitos ou JID completo).
        type:
          type: string
          description: Sempre sticker.
    SendLocationMessageRequest:
      type: object
      required: [to, latitude, longitude]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
        latitude:
          type: number
          format: double
          description: Latitude em graus decimais.
        longitude:
          type: number
          format: double
          description: Longitude em graus decimais.
        name:
          type: string
          description: Rotulo do local (opcional).
        address:
          type: string
          description: Endereco do local (opcional).
    SendContactMessageRequest:
      type: object
      required: [to, name, phone]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
        name:
          type: string
          description: Nome exibido no cartao de contato.
        phone:
          type: string
          description: Telefone do contato em E.164 apenas com digitos.
    SendStickerMessageRequest:
      type: object
      required: [to, mediaUrl]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
        mediaUrl:
          type: string
          format: uri
          description: URL publica do arquivo webp do sticker.
    SendReactionMessageRequest:
      type: object
      required: [to, messageId]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
        messageId:
          type: string
          description: ID da mensagem alvo da reacao.
        emoji:
          type: string
          description: Emoji da reacao. Envie vazio para remover a reacao.
        fromMe:
          type: boolean
          description: Indica se a mensagem alvo foi enviada pela propria instancia.
          default: false
    MarkReadRequest:
      type: object
      required: [to, messageIds]
      properties:
        to:
          type: string
          description: Destino. E.164 apenas com digitos ou JID completo.
        messageIds:
          type: array
          description: IDs das mensagens a marcar como lidas.
          items:
            type: string
        fromMe:
          type: boolean
          description: Indica se as mensagens foram enviadas pela propria instancia.
          default: false
    ConversationStatus:
      type: string
      enum: [open, pending, resolved]
      description: open = aberta; pending = pendente; resolved = encerrada.
    ConversationCounts:
      type: object
      description: >-
        Totais por status no projeto (para abas/contadores). Sempre refletem o
        projeto inteiro — independem da pagina atual e do filtro de `status`
        aplicado na listagem.
      properties:
        all:
          type: integer
          description: Total de conversas do projeto (todos os status somados).
        open:
          type: integer
          description: Quantidade de conversas com status `open` (abertas).
        pending:
          type: integer
          description: Quantidade de conversas com status `pending` (pendentes).
        resolved:
          type: integer
          description: Quantidade de conversas com status `resolved` (encerradas).
    ConversationSummary:
      type: object
      properties:
        id:
          type: string
          description: >-
            Identificador unico da conversa. Use-o nos endpoints
            `/v1/inbox/conversations/{id}` (detalhe, mensagens, read, status,
            assign, contact).
        instanceId:
          type: [string, 'null']
          description: >-
            Instancia pela qual a conversa entrou. Fica null se a instancia foi
            deletada — o historico da conversa PERMANECE.
        protocol:
          type: [string, 'null']
          description: >-
            Numero de protocolo (ticket) atribuido a conversa, no formato
            `AAAA-NNNNNN` (ano de abertura + sequencia de 6 digitos por projeto),
            ex.: `2026-000123`. Sequencial e unico dentro do projeto. Pode ser
            null em conversas legadas anteriores ao backfill de protocolo.
        contactId:
          type: string
          description: Identificador do contato (numero/pessoa) dono da conversa.
        contactName:
          type: [string, 'null']
          description: >-
            Nome do contato (do WhatsApp ou editado manualmente via
            `PATCH /v1/inbox/conversations/{id}/contact`). null se ainda nao
            houver nome conhecido.
        contactPhone:
          type: string
          description: >-
            Telefone do contato em digitos, com codigo do pais e sem simbolos
            (ex.: `5511999990000`).
        contactJid:
          type: string
          description: >-
            JID do contato no WhatsApp (remote_jid), ex.:
            `5511999990000@s.whatsapp.net`. E o endereco usado internamente para
            rotear as mensagens.
        status:
          $ref: '#/components/schemas/ConversationStatus'
        assignedUserId:
          type: [string, 'null']
          description: >-
            Id do atendente (usuario do manager) responsavel pela conversa.
            null = nao atribuida. Alterado via
            `POST /v1/inbox/conversations/{id}/assign`.
        unread:
          type: integer
          description: >-
            Quantidade de mensagens recebidas (INBOUND) ainda nao lidas. Zerado
            ao chamar `POST /v1/inbox/conversations/{id}/read`.
        lastMessageAt:
          type: [string, 'null']
          format: date-time
          description: >-
            Data/hora (ISO 8601, UTC) da ultima mensagem da conversa; usada para
            ordenar a listagem. null se a conversa ainda nao tem mensagens.
        lastMessageText:
          type: [string, 'null']
          description: >-
            Previa em texto da ultima mensagem (para o resumo na lista). null ou
            vazio quando a ultima mensagem nao tem texto (ex.: somente midia).
    InboxMessage:
      type: object
      properties:
        id:
          type: string
          description: >-
            Identificador unico da mensagem no nosso sistema. Tambem e o valor
            usado como cursor de scrollback (`before`/`nextBefore`) na listagem
            de mensagens.
        direction:
          type: string
          enum: [INBOUND, OUTBOUND]
          description: >-
            Sentido da mensagem: `INBOUND` = recebida do contato; `OUTBOUND` =
            enviada pela instancia.
        type:
          type: string
          description: 'text, image, video, audio, document, sticker, location, contact, poll ou unknown.'
        text:
          type: [string, 'null']
          description: >-
            Conteudo de texto da mensagem (ou a legenda, no caso de midia com
            caption). null quando a mensagem nao tem texto.
        mediaUrl:
          type: [string, 'null']
          description: >-
            URL pre-assinada (TTL ~6h) gerada na leitura. So presente em
            mensagens de midia. Releia o endpoint para obter uma URL nova — o
            binario fica no nosso storage por 2 anos (depois e removido; o texto
            da mensagem permanece). Para guardar a midia por mais tempo, baixe e
            armazene do seu lado.
        mediaMime:
          type: [string, 'null']
          description: >-
            Content-type (MIME) da midia, ex.: `image/jpeg`, `audio/ogg`,
            `application/pdf`. null em mensagens sem midia.
        status:
          type: string
          description: 'Para OUTBOUND: SENT, DELIVERED ou READ.'
        waMessageId:
          type: [string, 'null']
          description: >-
            Id da mensagem no WhatsApp. Use-o para correlacionar com os webhooks
            de status (message.status) e para citar a mensagem em respostas.
            null em mensagens sem id do WhatsApp.
        quotedWaMessageId:
          type: [string, 'null']
          description: >-
            Quando a mensagem e uma resposta/citacao, traz o `waMessageId` da
            mensagem citada. null em mensagens normais (sem citacao).
        createdAt:
          type: [string, 'null']
          format: date-time
          description: Data/hora (ISO 8601, UTC) de criacao da mensagem no nosso sistema.
    Webhook:
      type: object
      properties:
        id:
          type: string
          description: Identificador unico do webhook. Use-o em DELETE /v1/webhooks/{id}.
        name:
          type: string
          description: Rotulo livre do webhook, definido na criacao (apenas para sua organizacao/identificacao).
        url:
          type: string
          description: >-
            URL publica do SEU endpoint (no seu servidor) que recebe os POSTs de
            entrega. Voce fornece esta URL na criacao; o PEPINO ZAP NAO gera
            nenhuma URL (ao contrario de webhooks de Slack/Discord) — ele apenas
            faz POST nesta URL a cada evento.
        events:
          type: array
          items:
            type: string
          description: >-
            Eventos assinados. Vazio = todos. Valores possiveis: message.received,
            message.status, instance.status, call.received, message.deleted,
            group.participants, group.updated, contact.updated, label.updated,
            label.association.
        enabled:
          type: boolean
          description: >-
            Indica se o webhook esta ativo e recebendo entregas. Nasce true; o
            dispatcher so entrega aos webhooks com enabled=true.
        lastSuccessAt:
          type: [string, 'null']
          format: date-time
          description: >-
            Momento (UTC) da ultima entrega bem-sucedida (resposta 2xx do seu
            endpoint). null se ainda nao houve nenhuma entrega bem-sucedida.
        lastFailureAt:
          type: [string, 'null']
          format: date-time
          description: >-
            Momento (UTC) da ultima tentativa de entrega que falhou. null se
            nunca falhou. Compare com lastSuccessAt para inferir a saude atual.
        lastFailureReason:
          type: [string, 'null']
          description: >-
            Descricao curta do motivo da ultima falha (ex.: status HTTP de erro,
            erro de rede ou URL invalida; truncada em 500 caracteres). null se
            nunca falhou ou se a entrega seguinte foi bem-sucedida (zerada no
            sucesso).
        createdAt:
          type: string
          format: date-time
          description: Momento (UTC, RFC 3339) em que o webhook foi criado.
    CreateWebhookRequest:
      type: object
      required: [name, url]
      properties:
        name:
          type: string
          description: >-
            Rotulo livre para identificar o webhook (obrigatorio, nao pode ser
            vazio). Serve apenas para sua organizacao; nao precisa ser unico.
        url:
          type: string
          format: uri
          description: >-
            URL http/https publica do SEU endpoint (no seu servidor) que vai
            RECEBER os eventos. Voce a fornece; o PEPINO ZAP NAO gera nenhuma URL
            — ele faz POST nesta URL a cada evento. IP privado/localhost e
            rejeitado (anti-SSRF).
        events:
          type: array
          items:
            type: string
            enum:
              - message.received
              - message.status
              - instance.status
              - call.received
              - message.deleted
              - group.participants
              - group.updated
              - contact.updated
              - label.updated
              - label.association
          description: >-
            Vazio = assina todos os eventos. Valores validos: os 10 tipos
            suportados (ver o guia de Webhooks para a descricao de cada um).
    CreateWebhookResponse:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          properties:
            secret:
              type: string
              description: >-
                Secret (whsec_) para validar a assinatura X-Pepino-Signature.
                Mostrado UMA unica vez na criacao.
    Error:
      type: object
      description: >-
        Formato padrao de todas as respostas de erro da API. O corpo sempre tem
        a chave `error` com um `code` estavel (para tratamento programatico) e
        uma `message` legivel em portugues (que detalha o caso especifico).
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: >-
                Codigo estavel do erro em MAIUSCULAS (ex.: BAD_REQUEST,
                UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, PAYMENT_REQUIRED,
                RATE_LIMITED). Use-o para ramificar a logica; e mais estavel que
                a message.
              examples:
                - BAD_REQUEST
                - UNAUTHORIZED
            message:
              type: string
              description: >-
                Mensagem legivel em portugues explicando o caso especifico.
                Costuma distinguir sub-causas dentro de um mesmo code (ex.: as
                duas causas de 402) e e adequada para log/diagnostico, nao para
                exibir cru ao usuario final.
    ReportSummary:
      type: object
      description: Metricas agregadas do atendimento na janela escolhida.
      properties:
        range:
          type: object
          description: Janela efetiva (datas no fuso de Brasilia), ecoada da query.
          properties:
            from:
              type: string
              format: date
            to:
              type: string
              format: date
        total:
          type: integer
          description: Total de conversas abertas na janela.
        receptivos:
          type: integer
          description: Conversas RECEPTIVAS (o contato mandou a 1a mensagem).
        ativos:
          type: integer
          description: Conversas ATIVAS (a empresa iniciou o contato).
        pendentes:
          type: integer
          description: Conversas em status pending na janela.
        abertas:
          type: integer
          description: Conversas em status open na janela.
        resolvidas:
          type: integer
          description: Conversas resolvidas na janela.
        novosContatos:
          type: integer
          description: Contatos criados (primeiro contato) na janela.
        atendentes:
          type: object
          properties:
            total:
              type: integer
              description: Numero de atendentes do Projeto.
        primeiraResposta:
          type: object
          description: Tempo ate a primeira resposta humana, em segundos.
          properties:
            mediaSegundos:
              type: integer
            medianaSegundos:
              type: integer
            amostras:
              type: integer
              description: Quantas conversas entraram no calculo.
        porAtendente:
          type: array
          description: Produtividade por atendente na janela.
          items:
            type: object
            properties:
              userId:
                type: [string, 'null']
              nome:
                type: string
              email:
                type: string
              total:
                type: integer
              resolvidas:
                type: integer
        porDia:
          type: array
          description: Serie de conversas abertas por dia (para o grafico).
          items:
            type: object
            properties:
              dia:
                type: string
                format: date
              abertas:
                type: integer
    Label:
      type: object
      description: Etiqueta (tag) para classificar conversas.
      properties:
        id:
          type: string
        name:
          type: string
        color:
          type: string
          enum: [gray, green, blue, purple, amber, red, cyan, pink]
      required: [id, name, color]
    CreateLabelRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 40
          description: Nome da etiqueta (unico no Projeto).
        color:
          type: string
          enum: [gray, green, blue, purple, amber, red, cyan, pink]
          default: gray
    UpdateLabelRequest:
      type: object
      description: Campos a alterar (todos opcionais).
      properties:
        name:
          type: string
          maxLength: 40
        color:
          type: string
          enum: [gray, green, blue, purple, amber, red, cyan, pink]
    Department:
      type: object
      description: Departamento para organizar a equipe e rotear conversas.
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
          nullable: true
      required: [id, name]
    CreateDepartmentRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 50
          description: Nome do departamento (unico no Projeto).
        description:
          type: string
          nullable: true
          maxLength: 200
    UpdateDepartmentRequest:
      type: object
      description: Campos a alterar (todos opcionais).
      properties:
        name:
          type: string
          maxLength: 50
        description:
          type: string
          nullable: true
          maxLength: 200
    TransferDepartmentRequest:
      type: object
      description: Departamento de destino da conversa.
      properties:
        departmentId:
          type: string
          nullable: true
          description: Id do departamento de destino. `null` = fila geral (sem departamento).
    CloseReason:
      type: object
      description: Justificativa (motivo) de encerramento.
      properties:
        id:
          type: string
        name:
          type: string
        position:
          type: integer
          description: Ordem de exibicao no catalogo.
      required: [id, name, position]
    CreateCloseReasonRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          maxLength: 60
          description: Nome do motivo (unico no Projeto).
    SetConversationStatusRequest:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [open, pending, resolved]
        closeReasonId:
          type: [string, 'null']
          description: >-
            So ao resolver: id de um motivo do catalogo do Projeto. Precisa
            existir; invalido retorna 400.
        closeNotes:
          type: [string, 'null']
          description: 'So ao resolver: observacao livre do encerramento.'
    AssignConversationRequest:
      type: object
      properties:
        userId:
          type: [string, 'null']
          description: Atendente-alvo (do mesmo Projeto). null desatribui.
    Flow:
      type: object
      description: Fluxo de chatbot (cabecalho).
      properties:
        id:
          type: string
        instanceId:
          type: [string, 'null']
          description: Instancia (numero) vinculada. null = qualquer instancia.
        name:
          type: string
        triggerType:
          type: string
          enum: [any, keyword]
          description: any = qualquer mensagem inicia; keyword = so quando triggerValue casa.
        triggerValue:
          type: [string, 'null']
          description: Palavra-chave do gatilho (quando triggerType = keyword).
        active:
          type: boolean
          description: So 1 fluxo ativo por instancia.
      required: [id, name, triggerType, active]
    FlowNode:
      type: object
      description: >-
        No do fluxo. O `conteudo` e um objeto JSON cujo formato depende do
        `tipo` (veja o guia do construtor de fluxo).
      properties:
        id:
          type: string
        tipo:
          type: string
          enum: [message, menu, input, condition, action, delay, transfer, close]
        nome:
          type: [string, 'null']
        conteudo:
          type: object
          additionalProperties: true
        posicaoX:
          type: number
        posicaoY:
          type: number
        ordem:
          type: integer
          description: Menor ordem = no inicial do fluxo.
        proximoNodeId:
          type: [string, 'null']
          description: Saida padrao (aresta linear). Menus ligam por opcao.
      required: [id, tipo, conteudo, posicaoX, posicaoY, ordem]
    FlowWithNodes:
      type: object
      properties:
        flow:
          $ref: '#/components/schemas/Flow'
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/FlowNode'
    CreateFlowRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
        instanceId:
          type: [string, 'null']
          description: Instancia do Projeto a vincular (opcional).
        triggerType:
          type: string
          enum: [any, keyword]
          default: any
        triggerValue:
          type: [string, 'null']
    UpdateFlowRequest:
      type: object
      description: Campos a alterar (todos opcionais).
      properties:
        name:
          type: string
        instanceId:
          type: [string, 'null']
        triggerType:
          type: string
          enum: [any, keyword]
        triggerValue:
          type: [string, 'null']
        active:
          type: boolean
          description: true ativa (e desativa os outros da instancia); false desativa.
    CreateFlowNodeRequest:
      type: object
      required: [tipo]
      properties:
        tipo:
          type: string
          enum: [message, menu, input, condition, action, delay, transfer, close]
        nome:
          type: [string, 'null']
        conteudo:
          type: object
          additionalProperties: true
          description: Objeto JSON dependente do tipo (max. 16 KB).
        posicaoX:
          type: number
        posicaoY:
          type: number
        ordem:
          type: integer
    UpdateFlowNodeRequest:
      type: object
      description: Campos a alterar (todos opcionais).
      properties:
        nome:
          type: [string, 'null']
        conteudo:
          type: object
          additionalProperties: true
        posicaoX:
          type: number
        posicaoY:
          type: number
    SetNodeConnectionRequest:
      type: object
      properties:
        proximoNodeId:
          type: [string, 'null']
          description: No de destino. null desconecta.
    SetNodeOptionConnectionRequest:
      type: object
      required: [optionId]
      properties:
        optionId:
          type: string
          description: id da opcao em conteudo.options[].
        targetNodeId:
          type: [string, 'null']
          description: No de destino da opcao. null desliga a opcao.
    StageRule:
      type: object
      description: >-
        Regra de uma etapa do funil (achatada). Campos vazios/"any"/null = sem
        restricao; as condicoes presentes sao combinadas com E (AND). A conversa
        entra na etapa quando satisfaz todas.
      properties:
        origin:
          type: string
          enum: [any, inbound, outbound]
          description: any | receptiva (cliente iniciou) | ativa (empresa iniciou).
        status:
          type: string
          enum: [any, open, pending, resolved]
        labelId:
          type: [string, 'null']
          description: A conversa tem esta etiqueta.
        flowMode:
          type: string
          enum: [any, in_flow, at_node, completed, not_in_flow]
          description: >-
            Posicao no fluxo de bot: any | esta no fluxo | esta em um no | concluiu
            o fluxo | fora de qualquer fluxo.
        flowId:
          type: [string, 'null']
          description: Qual fluxo (para in_flow/at_node/completed). null = qualquer.
        nodeId:
          type: [string, 'null']
          description: Qual no (so para flowMode = at_node).
    Funnel:
      type: object
      properties:
        id:
          type: string
        instanceId:
          type: [string, 'null']
          description: Instancia de escopo. null = todas as instancias.
        name:
          type: string
      required: [id, name]
    FunnelStage:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        color:
          type: string
          enum: [gray, green, blue, purple, amber, red, cyan, pink]
        rule:
          $ref: '#/components/schemas/StageRule'
        posicaoX:
          type: number
        posicaoY:
          type: number
        position:
          type: integer
          description: Ordem no funil; maior = etapa mais avancada.
        proximoStageId:
          type: [string, 'null']
          description: Ligacao visual no canvas (cosmetica).
      required: [id, name, color, rule, posicaoX, posicaoY, position]
    FunnelWithStages:
      type: object
      properties:
        funnel:
          $ref: '#/components/schemas/Funnel'
        stages:
          type: array
          items:
            $ref: '#/components/schemas/FunnelStage'
    CreateFunnelRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
        instanceId:
          type: [string, 'null']
    UpdateFunnelRequest:
      type: object
      description: Campos a alterar (todos opcionais).
      properties:
        name:
          type: string
        instanceId:
          type: [string, 'null']
    CreateFunnelStageRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
        color:
          type: string
          enum: [gray, green, blue, purple, amber, red, cyan, pink]
          default: gray
        rule:
          $ref: '#/components/schemas/StageRule'
        posicaoX:
          type: number
        posicaoY:
          type: number
        position:
          type: integer
    UpdateFunnelStageRequest:
      type: object
      description: Campos a alterar (todos opcionais).
      properties:
        name:
          type: string
        color:
          type: string
          enum: [gray, green, blue, purple, amber, red, cyan, pink]
        rule:
          $ref: '#/components/schemas/StageRule'
        posicaoX:
          type: number
        posicaoY:
          type: number
        position:
          type: integer
    SetStageConnectionRequest:
      type: object
      properties:
        proximoStageId:
          type: [string, 'null']
          description: Etapa de destino da aresta visual. null desconecta.
    FunnelConvSample:
      type: object
      properties:
        id:
          type: string
        contactName:
          type: [string, 'null']
        contactPhone:
          type: [string, 'null']
        status:
          type: string
        lastMessageAt:
          type: [string, 'null']
          format: date-time
    FunnelData:
      type: object
      description: Funil avaliado — contagem e amostra de conversas por etapa.
      properties:
        funnel:
          $ref: '#/components/schemas/Funnel'
        stages:
          type: array
          items:
            allOf:
              - $ref: '#/components/schemas/FunnelStage'
              - type: object
                properties:
                  count:
                    type: integer
                    description: Total de conversas nesta etapa.
                  conversations:
                    type: array
                    description: Ate 50 conversas (as mais recentes) desta etapa.
                    items:
                      $ref: '#/components/schemas/FunnelConvSample'
        unplaced:
          type: integer
          description: Conversas avaliadas que nao casaram com nenhuma etapa.
        totalEvaluated:
          type: integer
          description: Total de conversas avaliadas (teto de 5000).
    CampaignStatus:
      type: string
      enum: [DRAFT, SCHEDULED, RUNNING, PAUSED, COMPLETED, CANCELLED]
      description: >-
        Estado da campanha. DRAFT: criada, nao disparada. SCHEDULED: agendada.
        RUNNING: disparando em background. PAUSED: pausada. COMPLETED: concluida.
        CANCELLED: cancelada.
    Campaign:
      type: object
      description: Resumo da campanha (item da listagem).
      properties:
        id:
          type: string
          description: Identificador unico da campanha.
        name:
          type: string
          description: Nome (rotulo interno) da campanha.
        status:
          $ref: '#/components/schemas/CampaignStatus'
        pausedReason:
          type: [string, 'null']
          description: >-
            Motivo da pausa quando PAUSED — "manual", "autopause:N_consecutivos"
            (auto-pausa por falhas) ou "banned". null caso contrario.
        totalRecipients:
          type: integer
          description: Total de destinatarios aceitos (apos normalizacao/dedup).
        sentCount:
          type: integer
          description: Quantidade de mensagens ja enviadas com sucesso.
        failedCount:
          type: integer
          description: Quantidade de envios que falharam.
        startedAt:
          type: [string, 'null']
          format: date-time
          description: Momento (UTC, RFC 3339) em que o disparo comecou. null enquanto nao iniciada.
        completedAt:
          type: [string, 'null']
          format: date-time
          description: Momento (UTC) em que terminou. null enquanto nao concluida.
        createdAt:
          type: [string, 'null']
          format: date-time
          description: Momento (UTC, RFC 3339) em que a campanha foi criada.
    Cadence:
      type: object
      description: >-
        Configuracao de cadencia anti-ban. Todos os campos sao opcionais na
        criacao/edicao; os omitidos usam defaults sensatos.
      properties:
        warmup:
          type: object
          description: 'Teto diario crescente: cap_do_dia = min(cap, ceil(base * factor^dia)).'
          properties:
            base: { type: number, description: 'Teto do 1o dia (default 20).' }
            factor: { type: number, description: 'Multiplicador por dia, >= 1 (default 1.2).' }
            cap: { type: integer, description: 'Teto maximo absoluto/dia (default 200).' }
        window:
          type: object
          description: Janela horaria de envio (no timezone). Fora dela, pausa e retoma na proxima abertura.
          properties:
            start: { type: string, description: 'Inicio HH:MM (default 08:00).' }
            end: { type: string, description: 'Fim HH:MM, deve ser > inicio (default 23:00).' }
            tz: { type: string, description: 'Timezone IANA (default America/Sao_Paulo).' }
        pacing:
          type: object
          description: Ritmo entre envios (sem rajada).
          properties:
            baseSeconds: { type: integer, description: 'Intervalo base em segundos (default 60).' }
            jitterPct:
              {
                type: number,
                description: 'Variacao aleatoria 0..1 aplicada ao intervalo (default 0.2 = +/-20%).',
              }
        autoPause:
          type: object
          properties:
            consecutiveThreshold:
              type: integer
              description: >-
                Pausa a campanha apos N falhas/invalidos CONSECUTIVOS (default 5;
                valores <= 0 usam o default). Para praticamente desligar, use um
                numero alto.
        validateNumbers:
          type: boolean
          description: >-
            Se true (default), valida no WhatsApp antes de enviar e marca o
            destinatario como "invalid" sem gastar o envio do cap.
    CampaignTotals:
      type: object
      description: Contagem de destinatarios por status atual.
      properties:
        total: { type: integer }
        pending: { type: integer, description: 'Pendentes (inclui os em envio).' }
        sent: { type: integer }
        delivered: { type: integer }
        read: { type: integer }
        responded: { type: integer }
        failed: { type: integer }
        invalid: { type: integer }
    CampaignDetail:
      type: object
      description: Detalhe da campanha com totais por status, cadencia e runtime.
      properties:
        id: { type: string }
        name: { type: string }
        status:
          $ref: '#/components/schemas/CampaignStatus'
        pausedReason:
          type: [string, 'null']
        template:
          type: string
          description: Mensagem-template (com variaveis {{...}}).
        totals:
          $ref: '#/components/schemas/CampaignTotals'
        cadence:
          $ref: '#/components/schemas/Cadence'
        runtime:
          type: object
          properties:
            dayIndex:
              { type: integer, description: 'Dia da campanha (0 = 1o dia), base do warmup.' }
            capToday: { type: integer, description: 'Teto de envios de hoje (warmup).' }
            sentToday:
              { type: integer, description: 'Enviados hoje (reseta no virar do dia, no tz).' }
            lastSentAt: { type: [string, 'null'], format: date-time }
            nextSendAt:
              {
                type: [string, 'null'],
                format: date-time,
                description: 'Proximo disparo agendado.',
              }
        startedAt: { type: [string, 'null'], format: date-time }
        completedAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: [string, 'null'], format: date-time }
    CampaignRecipientInput:
      description: >-
        Destinatario na criacao/adicao. Aceita uma string (so o numero) OU um
        objeto com numero, nome e variaveis.
      oneOf:
        - type: string
          description: Numero em qualquer formato (so digitos sao mantidos).
        - type: object
          required: [phone]
          properties:
            phone: { type: string, description: 'Numero (qualquer formato).' }
            name: { type: string, description: 'Nome — exposto no template como {{nome}}.' }
            vars:
              type: object
              additionalProperties: { type: string }
              description: 'Variaveis do template ({{chave}} -> valor).'
    CampaignRecipient:
      type: object
      description: Status individual de um destinatario.
      properties:
        id: { type: string, description: 'ID do destinatario (use no DELETE).' }
        recipient: { type: string, description: 'Numero (digitos) cadastrado.' }
        recipientJid: { type: [string, 'null'], description: 'JID canonico resolvido no envio.' }
        name: { type: string, description: 'Nome (de {{nome}}), se houver.' }
        status:
          type: string
          enum: [pending, sending, sent, delivered, read, responded, failed, invalid]
        error: { type: [string, 'null'], description: 'Motivo do erro (failed/invalid).' }
        sentAt: { type: [string, 'null'], format: date-time }
        waMessageId: { type: [string, 'null'] }
        nextSendAt: { type: [string, 'null'], format: date-time }
    CreateCampaignRequest:
      type: object
      required: [name, instanceId, template, recipients]
      properties:
        name:
          type: string
          description: Nome da campanha (rotulo interno). Trim aplicado; nao pode ficar vazio.
        instanceId:
          type: string
          description: >-
            ID da instancia (numero conectado) que fara os disparos. Deve
            pertencer ao Projeto e ao mesmo modo (test/live); senao 404.
        template:
          type: string
          description: >-
            Mensagem com variaveis {{nome}} e {{chave}} (resolvidas por
            destinatario). Aceita tambem o campo legado "body".
        recipients:
          type: array
          items:
            $ref: '#/components/schemas/CampaignRecipientInput'
          description: >-
            Lista de destinatarios (ao menos 1, no maximo 10.000 apos dedup).
        cadence:
          $ref: '#/components/schemas/Cadence'
    UpdateCampaignRequest:
      type: object
      description: Edicao parcial (so DRAFT/PAUSED). Campos omitidos ficam como estao.
      properties:
        name: { type: string }
        template: { type: string }
        cadence:
          $ref: '#/components/schemas/Cadence'
    AddRecipientsRequest:
      type: object
      required: [recipients]
      properties:
        recipients:
          type: array
          items:
            $ref: '#/components/schemas/CampaignRecipientInput'
    CreateCampaignResponse:
      type: object
      properties:
        id:
          type: string
          description: Identificador da campanha criada.
        totalRecipients:
          type: integer
          description: >-
            Total de destinatarios aceitos apos normalizacao/dedup (pode ser
            menor que a lista enviada).
    StartCampaignResponse:
      type: object
      properties:
        started:
          type: boolean
          description: Sempre true quando o disparo foi aceito.
    UpdateContactNameRequest:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Novo nome de exibicao do contato. Trim aplicado; nao pode ficar vazio.
    UpdateWebhookRequest:
      type: object
      required: [enabled]
      properties:
        enabled:
          type: boolean
          description: >-
            Liga (true) ou desliga (false) a entrega do webhook. Obrigatorio —
            ausente retorna 400. Unico campo editavel hoje.
  responses:
    BadRequest:
      description: >-
        A requisicao foi rejeitada por validacao antes de qualquer
        processamento. Causas tipicas: JSON malformado, campo obrigatorio
        ausente (ex.: nome do webhook vazio), valor invalido (evento fora de
        message.received/message.status/instance.status) ou URL nao aceita —
        precisa ser http/https publica; localhost, IP privado ou rede interna
        sao bloqueados (protecao anti-SSRF). A `message` indica o campo. Corrija
        o corpo e reenvie.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: BAD_REQUEST
              message: Body invalido
    Unauthorized:
      description: >-
        Falha de autenticacao: a API key nao foi reconhecida. Causas: header
        Authorization Bearer ausente; chave invalida/desconhecida; chave
        revogada; chave expirada; ou chave do MODO errado para o ambiente (key
        pzk_test_ chamando o host de producao, ou pzk_live_ chamando o host
        sandbox.* — o host decide o modo, e a key precisa casar). Confira o
        header Authorization, o ambiente (servidor producao vs. sandbox) e o
        prefixo da chave. Diferente de 403, aqui a identidade nem foi
        estabelecida.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNAUTHORIZED
              message: API key ausente ou invalida
    Forbidden:
      description: >-
        A chave foi autenticada com sucesso, mas o Projeto ao qual ela pertence
        nao esta ATIVO (foi suspenso ou desativado pelo operador) e por isso a
        operacao e negada. Diferente de 401 (a identidade aqui ja foi
        estabelecida) e de 402 (que e um gate de cobranca, resolvido assinando
        plano/recarregando credito). Para reativar o Projeto, fale com o
        operador/suporte.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: FORBIDDEN
              message: Projeto inativo ou sem permissao
    NotFound:
      description: >-
        O recurso referenciado pelo id no caminho nao existe ou nao pertence ao
        Projeto da API key. Nos endpoints de webhook, ocorre quando o webhook
        nao existe ou e de outro Projeto (a consulta e sempre escopada ao seu
        Projeto e ao modo test/live). Nos endpoints de instancia, quando a
        instancia nao existe nesse Projeto. Verifique o id e o ambiente
        (producao vs. sandbox).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: NOT_FOUND
              message: Instancia nao encontrada
    Conflict:
      description: >-
        Conflito de estado, distinguivel pela message. Em POST /v1/instances: a
        cota de numeros do plano foi atingida — contrate mais um numero em "Meu
        plano" (NAO e nome duplicado; nomes podem repetir). Nos endpoints de
        envio de mensagem: a instancia esta em estado terminal (BANNED ou ERROR)
        e nao pode enviar.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: CONFLICT
              message: limite de numeros do plano atingido — adicione um numero em "Meu plano"
    PaymentRequired:
      description: >-
        Pagamento necessario. Tem DUAS causas, distinguiveis pela message: (1)
        o Projeto nao tem assinatura ativa — criar instancias e enviar mensagens
        exige plano ativo; ou (2) projetos no modo API consomem creditos
        pre-pagos por mensagem enviada VIA API key (envios pelo painel nao
        consomem) e o saldo zerou. Resolva assinando um plano ou recarregando
        creditos em "Meu plano" no painel (projetos isentos, marcados pelo admin,
        nao recebem este erro). 402 e um gate de negocio: nao adianta repetir a
        chamada — exige acao humana no painel.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: PAYMENT_REQUIRED
              message: E necessario um plano ativo para usar este recurso. Assine em "Meu plano".

    RateLimited:
      description: >-
        Teto diario do modo TESTE atingido. So ocorre no modo teste
        (host sandbox.* / keys pzk_test_): cada Projeto pode enviar/receber ate
        100 mensagens por dia (dia em horario de Sao Paulo, somando enviadas +
        recebidas). Em producao este erro NAO ocorre — la o controle e a
        assinatura. Para volume real, use producao com um plano ativo.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: RATE_LIMITED
              message: limite diario do sandbox (100 mensagens/dia) atingido. Para volume real, use o ambiente de producao com um plano ativo.

```
