Guias/WebSocket de eventos
Documentação

WebSocket de eventos

Assinatura em tempo real de eventos por instância e eventos globais de Message Batch.

O WebSocket entrega, em tempo real e em modo best-effort, o mesmo JSON serializado para os webhooks. Headers HTTP de webhook nao sao enviados pelo WebSocket.

URLs

Local:

ws://localhost:8084/ws/instance/events?event=connection.update&token=<INSTANCE_JWT>
ws://localhost:8084/ws/global/events?event=message.batch.progress&token=<USER_JWT>
ws://localhost:8084/ws/instance/<INSTANCE_NAME>/calls/<CALL_ID>/media?token=<INSTANCE_JWT>

Producao deve usar wss://:

wss://api.example.com/ws/instance/events
wss://api.example.com/ws/global/events
wss://api.example.com/ws/instance/<INSTANCE_NAME>/calls/<CALL_ID>/media

Cada conexao de eventos assina exatamente um evento por query string. Nao ha wildcard, *, multiplos eventos separados por virgula ou troca de assinatura depois do upgrade. A rota /ws/instance/<INSTANCE_NAME>/calls/<CALL_ID>/media nao assina eventos; ela transporta midia binaria da chamada.

Autenticacao

/ws/instance/events aceita somente JWT de instancia. O token e validado com HS256 e AUTHENTICATION_JWT_SECRET, precisa conter instanceName, precisa pertencer ao registro Auth.token da instancia encontrada e entrega apenas eventos desse Instance.id.

/ws/instance/<INSTANCE_NAME>/calls/<CALL_ID>/media tambem aceita somente JWT de instancia. Alem das validacoes acima, o INSTANCE_NAME no path precisa ser igual ao claim instanceName do token. Isso impede abrir midia de chamada de outra instancia usando um token valido de instancia diferente.

Quando AUTHENTICATION_JWT_EXPIRES_IN > 0, a expiracao do token de instancia e validada no handshake e a conexao e fechada no instante de exp. Quando AUTHENTICATION_JWT_EXPIRES_IN=0, a expiracao do token de instancia pode ser ignorada e nao ha timer artificial.

/ws/global/events aceita somente JWT de usuario assinado com o mesmo segredo HS256. O claim userId em formato UUID e obrigatorio e identifica o dono dos eventos de lote. O claim exp e sempre obrigatorio e sempre validado, mesmo quando AUTHENTICATION_JWT_EXPIRES_IN=0.

O runtime atual nao possui tabela de usuarios. Por isso, a validacao do usuario no WebSocket e criptografica e baseada em userId; a associacao do lote e feita por ownerUserId, persistido a partir do mesmo claim na criacao do lote.

Parametros

ParametroObrigatorioDescricao
eventSimNome externo exato do evento. A comparacao e case-sensitive.
tokenSimJWT de instancia ou de usuario, conforme o endpoint.

Parametros especificos de /ws/instance/<INSTANCE_NAME>/calls/<CALL_ID>/media:

ParametroPadraoDescricao
audioSendtrueBrowser envia audio PCM para a chamada.
audioReceivetrueBrowser recebe audio remoto da chamada.
videoSendtrueBrowser envia video H.264 Annex-B para a chamada.
videoReceivetrueBrowser recebe video remoto H.264 Annex-B da chamada.

Eventos de chamada podem ser assinados em /ws/instance/events?event=<EVENTO>&token=<INSTANCE_JWT>. Para encerramento controlado, os eventos mais importantes sao:

  • call.hangup.requested
  • call.ending
  • call.terminate.sent
  • call.terminate.confirmed
  • call.terminate.retry
  • call.terminate.failed
  • call.ended
  • call.ended_unconfirmed

Ao receber apenas call.ending, o dashboard deve manter a UI em estado de encerramento e aguardar call.ended ou call.ended_unconfirmed.

Para iniciar midia local no dashboard, nao use call.outgoing, call.ringing, call.connecting, criacao da chamada ou carregamento da pagina como gatilho. O provider confirma que a sessao de midia esta pronta pelo callback interno ready; a API persiste status=ACTIVE, publica call.active e em seguida publica call.ready. O gatilho recomendado para solicitar microfone e abrir o WebSocket binario e event === "call.ready" com call.status === "ACTIVE". Como fallback de reconciliacao, ao carregar a pagina consulte GET /call/{instanceName}/{callId} e inicie a midia apenas se a chamada persistida estiver ACTIVE e nao terminal.

Respostas antes do upgrade:

StatusCaso
400event ou token ausente, evento malformado ou evento fora da allowlist.
401Token invalido, expirado ou assinatura incorreta.
403Token valido, mas do escopo errado, sem permissao, ou origem recusada.
404Instancia referenciada pelo token de instancia nao encontrada.
426Rota chamada sem upgrade WebSocket.
500Falha interna inesperada.

Midia de chamadas

A rota de midia e bidirecional e usa frames WebSocket binarios. Ela e separada do WebSocket de eventos.

GET /ws/instance/{instanceName}/calls/{callId}/media?token=<INSTANCE_JWT>

callId aceita UUID interno da chamada ou provider call ID. A chamada precisa estar em CONNECTING ou ACTIVE.

Header binario:

OffsetTamanhoDescricao
04Magic WMC1.
41Versao 1.
51Tipo: 1 audio PCM float32, 2 video H.264 Annex-B.
62Reservado.
84Duracao em ms, big-endian.
124Tamanho do payload, big-endian.
16NPayload.

Payloads:

  • Tipo 1: PCM float32 little-endian, mono, 16 kHz. Use frames curtos, idealmente 60 ms.
  • Tipo 2: access unit H.264 Annex-B bruto. Use a duracao no header para controlar o timestamp de video.

Exemplo de empacotamento no browser:

function mediaFrame(kind, payload, durationMs = 0) {
  const header = new ArrayBuffer(16);
  const view = new DataView(header);
  view.setUint8(0, 0x57); // W
  view.setUint8(1, 0x4d); // M
  view.setUint8(2, 0x43); // C
  view.setUint8(3, 0x31); // 1
  view.setUint8(4, 1);
  view.setUint8(5, kind);
  view.setUint32(8, durationMs, false);
  view.setUint32(12, payload.byteLength, false);
  const out = new Uint8Array(16 + payload.byteLength);
  out.set(new Uint8Array(header), 0);
  out.set(new Uint8Array(payload), 16);
  return out;
}

const ws = new WebSocket(`ws://localhost:8084/ws/instance/test_001/calls/${callId}/media?token=${token}`);
ws.binaryType = "arraybuffer";

// Enviar audio PCM float32 mono 16 kHz.
ws.send(mediaFrame(1, float32PcmBuffer));

// Enviar access unit H.264 Annex-B.
ws.send(mediaFrame(2, h264AccessUnit, 66));

O servidor envia os mesmos tipos de frame no sentido API -> browser. No recebimento, leia o header, verifique WMC1, version=1, kind e payloadLength.

Eventos de instancia

FlagEvento
callUpsertcall.upsert
chatsDeletedchats.delete
chatsUpdatedchats.updated
connectionUpdatedconnection.update
contactsUpdatedcontacts.update
contactsUpsertcontacts.upsert
groupsParticipantsUpdatedgroups.participants.update
groupsUpdatedgroups.update
groupsUpsertgroups.upsert
historySynchistory.sync
identityUpdatedidentity.update
labelsAssociationlabels.association
labelsEditlabels.edit
mediaRetrymedia.retry
messagesDeletedmessages.delete
messagesStarredmessages.star
messagesUndecryptablemessages.undecryptable
messagesUpdatedmessages.update
messagesUpsertmessages.upsert
newsLetternews.letter
presenceUpdatedpresence.updated
profilePictureUpdatedprofile.picture.update
qrcodeUpdatedqrcode.updated
sendMessagesend.message
settingsUpdatedsettings.update
statusInstancestatus.instance
userAboutUpdateduser.about.update

messaging-history.set nao e disponibilizado. history.sync e aceito normalmente.

Eventos globais

Evento
message.batch.created
message.batch.scheduled
message.batch.started
message.batch.window-started
message.batch.waiting-window
message.batch.recovered
message.batch.progress
message.batch.pause-requested
message.batch.paused
message.batch.waiting-instance
message.batch.resumed
message.batch.stop-requested
message.batch.stopped
message.batch.interrupted
message.batch.completed
message.batch.completed-with-errors
message.batch.item.failed
message.batch.item.unknown

Eventos globais sao isolados por ownerUserId == userId do JWT de usuario.

Payload

Eventos de instancia usam exatamente o envelope de internal/webhook.WebhookPayload:

{
  "event": "connection.update",
  "instance": {"id": 1, "name": "minha-instancia", "connectionStatus": "online", "ownerJid": "[email protected]", "externalAttributes": {}},
  "data": {"type": "connected", "connection": "open"},
  "timestamp": "2026-07-14T12:00:00Z"
}

messages.upsert:

{
  "event": "messages.upsert",
  "instance": {"id": 1, "name": "minha-instancia", "connectionStatus": "online", "ownerJid": "[email protected]", "externalAttributes": {}},
  "data": {
    "id": 1001,
    "keyRemoteJid": "[email protected]",
    "keyLid": null,
    "keyFromMe": false,
    "keyParticipant": null,
    "keyParticipantLid": null,
    "pushName": "Contato",
    "messageType": "text",
    "content": {"text": "Ola"},
    "messageTimestamp": 1784040000,
    "device": "web",
    "isGroup": false,
    "metadata": {}
  },
  "timestamp": "2026-07-14T12:00:00Z"
}

qrcode.updated:

{
  "event": "qrcode.updated",
  "instance": {"id": 1, "name": "minha-instancia", "connectionStatus": "qr_code", "ownerJid": null, "externalAttributes": {}},
  "data": {"count": 1, "code": "2@codigo-ficticio", "base64": "data:image/png;base64,iVBORw0KGgoAAA...", "expiresInSeconds": 30, "expiresAt": "2026-07-14T12:00:30Z"},
  "timestamp": "2026-07-14T12:00:00Z"
}

Eventos globais usam o envelope de lote:

{
  "event": "message.batch.progress",
  "timestamp": "2026-07-14T12:00:00Z",
  "data": {
    "batch": {
      "id": "019f6086-ce48-7ba3-83ca-02c15405d570",
      "ownerUserId": "11111111-1111-4111-8111-111111111111",
      "name": "campanha",
      "status": "PROCESSING",
      "instances": [{"id": 1, "name": "minha-instancia", "connectionStatus": "online"}],
      "counts": {"total": 10, "processed": 5, "pending": 5, "sending": 0, "success": 5, "failed": 0, "skipped": 0, "unknown": 0},
      "progress": 50,
      "createdAt": "2026-07-14T11:59:00Z",
      "updatedAt": "2026-07-14T12:00:00Z",
      "currentItem": {"id": "019f6087-0000-7000-8000-000000000001", "position": 4, "recipient": "5511888888888", "instanceName": "minha-instancia"}
    }
  }
}

message.batch.completed:

{
  "event": "message.batch.completed",
  "timestamp": "2026-07-14T12:05:00Z",
  "data": {
    "batch": {
      "id": "019f6086-ce48-7ba3-83ca-02c15405d570",
      "ownerUserId": "11111111-1111-4111-8111-111111111111",
      "name": "campanha",
      "status": "COMPLETED",
      "instances": [{"id": 1, "name": "minha-instancia", "connectionStatus": "online"}],
      "counts": {"total": 10, "processed": 10, "pending": 0, "sending": 0, "success": 10, "failed": 0, "skipped": 0, "unknown": 0},
      "progress": 100,
      "completedAt": "2026-07-14T12:05:00Z",
      "createdAt": "2026-07-14T11:59:00Z",
      "updatedAt": "2026-07-14T12:05:00Z"
    }
  }
}

message.batch.item.failed:

{
  "event": "message.batch.item.failed",
  "timestamp": "2026-07-14T12:03:00Z",
  "data": {
    "batchId": "019f6086-ce48-7ba3-83ca-02c15405d570",
    "itemId": "019f6087-0000-7000-8000-000000000009",
    "recipient": "5511777777777",
    "error": {"code": "SEND_MESSAGE_FAILED", "message": "Falha ao enviar a mensagem."},
    "instance": {"id": 1, "name": "minha-instancia"}
  }
}

Cliente JavaScript

function createSocketClient({ url, token, reconnectInterval = 5000 }) {
  return function socket(eventName, callback) {
    let ws = null;
    let reconnectTimer = null;
    let stopped = false;

    function connect() {
      const query = new URLSearchParams({ event: eventName, token });
      ws = new WebSocket(`${url}?${query.toString()}`);
      ws.onmessage = (messageEvent) => callback?.(JSON.parse(messageEvent.data), messageEvent);
      ws.onclose = () => {
        if (!stopped) reconnectTimer = setTimeout(connect, reconnectInterval);
      };
    }

    connect();

    return {
      close(code = 1000, reason = "Client closed connection") {
        stopped = true;
        if (reconnectTimer) clearTimeout(reconnectTimer);
        if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) ws.close(code, reason);
      },
      get connection() {
        return ws;
      }
    };
  };
}

const instanceSocket = createSocketClient({url: "ws://localhost:8084/ws/instance/events", token: "jwt-da-instancia"});
const connectionSubscription = instanceSocket("connection.update", (data) => console.log("connection.update", data));
const messagesSubscription = instanceSocket("messages.upsert", (data) => console.log("messages.upsert", data));
const qrcodeSubscription = instanceSocket("qrcode.updated", (data) => console.log("qrcode.updated", data));

const globalSocket = createSocketClient({url: "ws://localhost:8084/ws/global/events", token: "jwt-do-usuario"});
const progressSubscription = globalSocket("message.batch.progress", (data) => console.log("message.batch.progress", data));
const completedSubscription = globalSocket("message.batch.completed", (data) => console.log("message.batch.completed", data));
const failedItemSubscription = globalSocket("message.batch.item.failed", (data) => console.log("message.batch.item.failed", data));

Heartbeat e fechamento

O servidor envia ping periodico, atualiza o deadline ao receber pong e aplica write deadline. O browser responde aos frames ping automaticamente.

CodigoSignificado
1000Encerramento normal.
1001Servidor encerrando ou conexao removida.
1008Mensagem de aplicacao enviada pelo cliente ou violacao de politica.
1011Erro interno inesperado.
4001Token expirou durante a conexao.
4003Permissao revogada ou escopo nao permitido.
4008Cliente lento ou buffer de envio excedido.

Configuracao

VariavelPadraoDescricao
WEBSOCKET_ENABLEDtrueRegistra ou desabilita as rotas /ws/....
WEBSOCKET_ALLOWED_ORIGINSvazioLista separada por virgula de origens aceitas. Use origens explicitas em producao.
WEBSOCKET_ALLOW_EMPTY_ORIGINtruePermite clientes backend sem header Origin.
WEBSOCKET_PING_INTERVAL25sIntervalo de ping do servidor.
WEBSOCKET_PONG_TIMEOUT60sPrazo para receber pong.
WEBSOCKET_WRITE_TIMEOUT10sPrazo de escrita por frame.
WEBSOCKET_SEND_BUFFER256Buffer por cliente. Cliente lento e removido quando o buffer enche.

Seguranca e troubleshooting

O token vai na query string porque WebSocket nativo no browser nao permite header Authorization customizado. A API nao registra query string nas rotas WebSocket, nao devolve JWT no payload e nao inclui tokens em logs. Configure proxy e observabilidade para nao registrar a query completa.

Nao ha replay automatico. Se o cliente estiver desconectado, eventos podem ser perdidos; use os endpoints REST para reconstruir estado persistente.

SintomaCausa provavel
426A chamada nao fez upgrade WebSocket.
400Evento ausente, errado, com caixa incorreta ou fora da allowlist.
401JWT invalido, expirado ou sem exp no caso de usuario.
403Token de outro escopo, origem recusada ou token de instancia diferente do Auth.token.
Fecha com 4001O token expirou depois da conexao estabelecida.