SoftphONE
Documentação técnica

Guia do Desenvolvedor

Manual de integração do SoftphONE (softphone) a um sistema legado: protocolo completo, modelo de dados recomendado, exemplos de código e o que fica por conta do seu sistema. Escrito pra um dev que nunca viu este projeto conseguir integrar sem precisar perguntar nada.

1. Arquitetura: quem é dono de quê

SoftphONE (softphone): registra no PABX (SIP/WebRTC), desenha teclado, discagem, histórico e cronômetro de chamada, toca o som de chamada quando usado sozinho (standalone). Não guarda credencial nenhuma além do que chega por postMessage, nunca em localStorage/cookie quando embutido.

Seu sistema (host): dono de todo dado de negócio (quem é o usuário, qual ramal ele usa, preferências de dispositivo de áudio e volume de toque) e da UI de configuração dessas coisas. O SoftphONE não tem tela nenhuma pra isso quando embutido: nem seletor de dispositivo, nem cadastro de ramal. Essa UI mora inteira no seu sistema.

A comunicação entre os dois é só postMessage, entre o <iframe> do SoftphONE e a página do seu sistema. Nenhum acesso direto ao DOM do outro lado (cross-origin, nem daria).

Prefixo do protocolo: as mensagens usam softphone: (ex. softphone:config, softphone:cmd, softphone:estado). Integrações feitas antes de 2026-09-17 usam o prefixo antigo ctxsip: — o SoftphONE aceita e manda os dois ao mesmo tempo, nada quebra pra quem já integrou. Integrações novas devem usar só softphone:.

2. Infra: publicar e autorizar

  1. Publique o SoftphONE uma vez: a mesma instância serve todos os sistemas que forem integrar.
  2. Autorize a origem do seu sistema: a variável de ambiente SOFTPHONE_ALLOWED_ORIGINS do deploy do SoftphONE precisa incluir a origem exata (scheme + host + porta) do seu sistema. Sem isso, o CSP bloqueia o iframe em silêncio (a tela fica em branco, sem erro no console) e a mensagem de config nunca chega (a origem não bate na allowlist do listener).
  3. Aponte pra URL do SoftphONE via variável de ambiente, nunca hardcoded: dev e produção apontam pra instâncias diferentes.

3. Embutir o iframe

<iframe src="https://softphone.exemplo.com/phone/index.html?embedded=1"
        allow="microphone; autoplay"
        style="width:100%; height:520px; border:0;"></iframe>
allow="microphone; autoplay" é obrigatório. Sem isso o navegador nega a permissão de microfone dentro do iframe mesmo se o usuário aceitar o prompt, e o áudio remoto não toca sozinho.

Mantenha o iframe sempre montado enquanto o softphone estiver "ligado" pro usuário: só a altura visível deve mudar (CSS), nunca desmontar/remontar o elemento a cada navegação de tela. Desmontar derruba o registro SIP.

Gotcha real (produção, 2026-09-13): force um remonte (trocar a key do elemento, em React, ou recriar o nó) quando as credenciais mudarem e reenviar softphone:config não bastar. Sem isso, credenciais recém-cadastradas podiam exigir recarregar a página inteira pra funcionar.

4. Handshake de credenciais

const ORIGEM_SOFTPHONE = 'https://softphone.exemplo.com';
const iframe = document.getElementById('softphone');

window.addEventListener('message', (event) => {
  if (event.origin !== ORIGEM_SOFTPHONE) return;
  const msg = event.data;

  if (msg.type === 'softphone:ready') {
    iframe.contentWindow.postMessage({
      type: 'softphone:config',
      user: ramalDoUsuario, pass: senhaDoRamal, realm: 'pbx.exemplo.com',
      wsServer: 'wss://pbx.exemplo.com:8089/ws',
      iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], // opcional, seção 8
      micId: prefUsuario.micId,     // opcional, seção 9
      saidaId: prefUsuario.saidaId, // opcional, seção 9
      skin: prefUsuario.skin,       // opcional, seção 13
    }, ORIGEM_SOFTPHONE);
  }

  if (msg.type === 'softphone:estado') { /* seção 7 */ }
});

Nunca hardcode credencial nenhuma no HTML/JS do seu sistema. Ela é sempre resolvida em runtime a partir do usuário logado, do lado do servidor, nunca guardada em texto puro no frontend além do momento de mandar pro iframe.

5. Modelo de dados recomendado

Baseado no que a integração mais completa hoje (SuporteONE) guarda de verdade:

Por instalação (1 config só, vale pra todos os usuários)

  • Servidores ICE (STUN/TURN): STUN público basta em rede aberta; TURN é necessário pra quem está atrás de NAT simétrico (4G, alguns roteadores) conseguir áudio.

Por usuário

  • Interruptor ligado/desligado (desligado = não registra, não toca, não recebe).
  • Até 3 ramais salvos (nome, ramal SIP, senha, host WebSocket), com um marcado ativo. Senha nunca volta do servidor em texto puro: campo de edição fica vazio.
  • Preferência de dispositivo de áudio (micId/saidaId, seção 9).
  • Preferência de toque (volume, mudo, som): isso é do seu sistema, não do SoftphONE: quando embutido, o SoftphONE desliga o próprio ring de propósito (evita duplicar som).
  • Opcional: um "DDD local" por ramal, se o click-to-dial da sua plataforma for discar números com DDD (ver seção 12).

6. Comandos remotos (softphone:cmd)

Todos no formato { type: 'softphone:cmd', cmd: '<nome>', ...campos }, mandados pro iframe.contentWindow:

cmdCamposEfeito
chamarnumero, nome (opcional)Disca. Com nome, a sessão de saída já nasce com esse nome de exibição em vez do número cru, útil pra click-to-dial a partir de um contato já conhecido.
atender(nenhum)Atende a chamada de entrada ativa
recusar(nenhum)Recusa a chamada de entrada ativa
encerrar(nenhum)Desliga a chamada ativa
mudo(nenhum)Alterna mudo
reter(nenhum)Alterna hold
dtmftomManda um dígito DTMF
transferirCegadestinoTransferência cega (sem consulta prévia; attended não existe ainda)
nomearChamadornumero, nomeVer seção 11 (caller ID de entrada)
trocarMicdeviceIdVer seção 9
trocarSaidadeviceIdVer seção 9
trocarSkinskinVer seção 13
teclateclaRepassa tecla do teclado físico: 0-9, *, #, Backspace, ArrowLeft, ArrowRight, Escape ou Enter. Dígitos entram no display na posição do cursor e, em chamada, também mandam DTMF (igual clicar no teclado). Backspace apaga o caractere antes do cursor, ArrowLeft/ArrowRight movem o cursor, Escape limpa o display. Enter disca como o botão Ligar, mas só com o softphone ocioso e com número no display (em chamada é ignorado). Veja a nota abaixo

Teclado físico com o foco no host. O softphone é um iframe, então tecla digitada com o foco na página do host nunca chega nele. Para o usuário discar sem clicar no display, o host escuta keydown no document enquanto o widget estiver aberto e, se o alvo não for input, textarea, select ou contenteditable, e não houver Ctrl/Meta/Alt, repassa 0-9, *, #, Backspace, ArrowLeft, ArrowRight e Escape com { type: 'softphone:cmd', cmd: 'tecla', tecla: event.key } (chamando preventDefault()). Enter com cuidado: ele só deve ser repassado quando o alvo não for botão, link, campo editável nem item com role interativo, não for tecla repetida (event.repeat) e o host souber que o usuário acabou de digitar um número pelo teclado (por exemplo, um dígito repassado desde o último Enter). Fora disso, um Enter global acionaria botões ou formulários da página do host. Com o foco dentro do iframe o próprio softphone já trata essas teclas, inclusive o Enter, sem ajuda do host.

Troca de ramal em runtime: reenviar softphone:config inteiro com as credenciais do ramal novo. O SoftphONE detecta que já está montado e faz stop()/start() sozinho, não precisa remontar o iframe. Só é recusado se houver chamada ativa (ver softphone:trocaRamalRecusada na seção 7). Sempre reenvie a config completa, não só o campo que mudou.

7. Estado publicado (softphone:estado)

O SoftphONE publica patches (só o que mudou, nunca o objeto inteiro) a cada evento relevante. Mantenha um snapshot local no seu sistema (merge de cada patch). Campos principais: registro (desligado/conectando/registrado/reconectando/falha), chamada (ocioso/tocando/chamando/em_chamada/encerrada), direcao, numero, nomeRemoto, mudo, retida, encerradaMotivo, dispositivos ({micId, saidaId} efetivos, seção 9).

Derive "eventos de negócio" a partir da transição de chamada pra timeline/notificação: ocioso→tocando com direcao:'entrada' é "chamada entrando"; *→em_chamada é "atendida"/"iniciada"; *→encerrada é "encerrada" (com encerradaMotivo).

Recusas chegam como mensagens próprias, não como patch de estado:

{ type: 'softphone:trocaRamalRecusada', motivo: 'chamada_ativa' }
{ type: 'softphone:trocaDispositivoRecusada', qual: 'mic'|'saida', motivo: 'getUserMedia_falhou'|'setSinkId_indisponivel'|'deviceId_invalido' }

Em ambos os casos o SoftphONE não aplicou nada. É aviso pro seu sistema decidir como informar o usuário (toast, reverter um seletor de UI).

8. Servidores ICE (STUN/TURN)

Repassados no softphone:config (campo iceServers, array de {urls, username?, credential?}). Sem TURN, usuários atrás de NAT simétrico (comum em rede móvel) registram mas não trocam áudio. Sintoma típico: liga, atende, fica mudo dos dois lados. Inclua pelo menos um STUN público (stun:stun.l.google.com:19302) como piso.

9. Seleção de dispositivos de áudio (mic/saída)

  • Quem enumera os dispositivos (navigator.mediaDevices.enumerateDevices()) é o seu sistema, na própria origem: os deviceId valem dentro do iframe também (mesmo navegador, mesma permissão de mídia).
  • Os labels só vêm preenchidos depois de uma permissão de microfone concedida NA ORIGEM DO SEU SISTEMA: peça getUserMedia({audio:true}) uma vez, e pare a track logo depois (só precisa da permissão, não do áudio).
  • Esconda o seletor de saída se typeof HTMLMediaElement.prototype.setSinkId !== 'function' (Firefox não implementa até a versão testada aqui).
  • deviceId: 'default' (ou campo ausente) sempre significa "deixa o sistema decidir": nunca mande um deviceId de verdade pra dizer isso.
  • Trocar aplica na hora, mesmo com chamada em andamento, sem re-INVITE. Pode mandar direto do onChange de um <select>.
  • Persistir a escolha é responsabilidade do seu sistema, por usuário: reenvie micId/saidaId salvos a cada softphone:config novo (boot ou troca de ramal), senão a escolha se perde a cada reconexão.
  • Reaja a softphone:trocaDispositivoRecusada revertendo o seletor pro valor anterior (guarde o valor anterior antes de aplicar a troca otimisticamente na UI).
  • Trate softphone:estado.dispositivos como fonte de verdade do valor efetivo em uso: uma recusa ou um fallback (dispositivo salvo que sumiu) pode mudar o valor real sem seu sistema saber por outro canal.

10. O que seu sistema faz por conta própria

O SoftphONE embutido desliga deliberadamente o próprio ring e várias features de UI standalone. Se seu sistema quiser essas coisas, precisa implementar do lado dele:

Som de toque de chamada

Sintetizar/tocar um áudio próprio ao ver chamada:'tocando' com direcao:'entrada', com volume/mudo/som configuráveis por usuário.

Notificação do sistema operacional

Peça permissão (Notification.requestPermission(), só quando o usuário liga o softphone, nunca sozinho ao carregar a página) e dispare a notificação na mesma transição de estado, com tag fixa (substitui em vez de empilhar). Mostre só quando a aba está oculta OU sem foco (document.hidden || !document.hasFocus()). Com a aba visível e em foco, o widget já avisa, e a notificação seria redundante.

Botões de ação na própria notificação (Atender/ Recusar/Mudo) exigem showNotification() via Service Worker, não new Notification() direto: a API direta não suporta o campo actions. Padrão de referência: um SW dedicado registra o handler de notificationclick e manda postMessage de volta pra página ({tipo:'<seu-app>-softphone-acao', acao:'atender'|'recusar'|'mute'}), que a página escuta via navigator.serviceWorker.addEventListener('message', ...) e traduz pro softphone:cmd correspondente. Sem SW, caia pra new Notification() simples com onclick que foca a janela.

Piscar o título da aba

Enquanto a chamada toca (document.title alternando), pra chamar atenção mesmo sem clicar na notificação.

UI de configuração inteira

Ramais, dispositivo de áudio, preferências de toque, liga/desliga, interruptor de notificação: o SoftphONE embutido não desenha nada disso.

11. Caller ID (opcional)

O SoftphONE só recebe e mostra: resolver o nome é trabalho 100% do seu sistema. Existem dois mecanismos, cada um pro seu timing de chamada:

  • Entrada: nomearChamador. O número já chegou (chamada já tocando), o nome se resolve depois, de forma assíncrona, e casa por número numa sessão que já existe.
  • Saída (click-to-dial): campo nome direto no chamar (seção 6). Quando seu sistema JÁ SABE o nome antes de discar, manda junto do comando, sem round-trip extra.
// disparado ao ver chamada:'tocando' com direcao:'entrada' no softphone:estado
fetch(`/api/caller-id?numero=${numeroLimpo}`)
  .then((r) => r.ok ? r.json() : null)
  .then((r) => {
    if (!r || !r.nome) return; // sem nome: bolha fica com o número cru
    iframe.contentWindow.postMessage({
      type: 'softphone:cmd', cmd: 'nomearChamador', numero: numeroOriginal, nome: r.nome,
    }, ORIGEM_SOFTPHONE);
  })
  .catch(() => {}); // caller ID é enfeite, erro nunca trava a ligação
Regra de segurança: se a busca por número bater em mais de um cadastro (ambíguo), NUNCA aplique um nome: mostrar o nome errado de outro cliente é pior que mostrar só o número.

12. Referência: o que o SuporteONE já construiu

A integração mais completa hoje. Nada disso é código do SoftphONE: é tudo host consumindo o protocolo acima. Serve de referência de "até onde dá pra ir":

  • Click-to-dial em toda a plataforma: um componente de botão reutilizável (chamados, clientes, contatos, propostas) decide no clique se usa o softphone (ativo && registro==='registrado' → manda chamar com numero+nome) ou cai pro fluxo antigo (PABX tradicional) quando o softphone está desligado ou ainda sem registro.
  • DDD local por ramal: o PABX pode rejeitar (403 Forbidden) uma chamada pra número local (mesmo DDD do ramal) discada COM o DDD. Fix: guardar um "DDD local" por ramal e tirar o DDD do número só quando ele bate com o DDD local E o resultado sobra com 8 ou 9 dígitos (fixo/celular válido). Nunca mexer em número de outro DDD.
  • Notificação nativa com botões de ação: Service Worker próprio (padrão descrito na seção 10), com interruptor de liga/desliga por usuário.
  • Volume/mudo/som de toque: 3 sons sintetizados no navegador, com botão "Testar som" na própria tela de configuração.
  • Até 3 ramais salvos por usuário, com troca de ativo sem repedir senha.
  • Seleção de mic/saída de áudio (seção 9), com troca ao vivo mesmo em chamada.

13. Skins (aparência, opcional)

6 skins fixas (não dá pra criar uma nova sem mexer no código do SoftphONE), cada uma um skin válido — passe a string, nunca componha tema-modo na mão:

skinNome exibido
classic-darkClássico Escuro (default, visual original)
classic-lightClássico Claro
vibrant-darkVibrante Escuro
vibrant-lightVibrante Claro
soft-darkSuave Escuro
soft-lightSuave Claro
  • Campo skin no softphone:config (handshake ou reconfig) aplica no boot/troca de ramal — ver exemplo na seção 4. Ausente ou fora da lista acima: primeira config cai no default classic-dark; reconfig com skin inválido/ausente mantém a skin em vigor, não reverte pro default.
  • Comando trocarSkin (seção 6) aplica na hora, quantas vezes seu sistema mandar, mesmo com chamada ativa: ao contrário de troca de dispositivo/ramal, nunca é recusado (não existe softphone:trocaSkinRecusada).
  • Persistir a escolha é responsabilidade do seu sistema (por usuário), igual dispositivo de áudio: o SoftphONE embutido não guarda a skin sozinho. Reenvie skin salvo a cada softphone:config novo, senão a escolha se perde a cada reconexão.
  • Standalone (sem host) tem seletor próprio na tela de Configurações (⚙️), que some no modo embutido: a escolha ali é local (localStorage), sem relação com o skin do protocolo.

14. Checklist de integração

  • Publicar/confirmar o SoftphONE acessível pro seu sistema (dev primeiro, depois produção).
  • Adicionar sua origem em SOFTPHONE_ALLOWED_ORIGINS no deploy do SoftphONE.
  • Guardar a URL do SoftphONE em config/env, nunca hardcoded.
  • Embutir o iframe ?embedded=1 com allow="microphone; autoplay", sempre montado.
  • Handshake softphone:ready → softphone:config com credenciais resolvidas no servidor.
  • Modelar dados: ICE por instalação; ramais, dispositivo de áudio e toque por usuário.
  • Construir a UI de configuração inteira: 100% do seu sistema.
  • Ligar softphone:estado a um snapshot local + eventos de negócio derivados.
  • Tratar as mensagens de recusa (reverter UI, avisar usuário).
  • Decidir se quer som de toque próprio, notificação do SO e caller ID: nenhum vem de graça.
  • Testar com chamada real: registro, discagem, recebimento, hold, mudo, transferência, reconexão.
  • (Opcional) Skin: se quiser deixar o usuário escolher aparência, UI própria + persistência + campo skin/comando trocarSkin (seção 13).