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