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).
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
- Publique o SoftphONE uma vez: a mesma instância serve todos os sistemas que forem integrar.
- Autorize a origem do seu sistema: a variável de ambiente
SOFTPHONE_ALLOWED_ORIGINSdo 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). - 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>
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.
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:
| cmd | Campos | Efeito |
|---|---|---|
chamar | numero, 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 |
dtmf | tom | Manda um dígito DTMF |
transferirCega | destino | Transferência cega (sem consulta prévia; attended não existe ainda) |
nomearChamador | numero, nome | Ver seção 11 (caller ID de entrada) |
trocarMic | deviceId | Ver seção 9 |
trocarSaida | deviceId | Ver seção 9 |
trocarSkin | skin | Ver seção 13 |
tecla | tecla | Repassa 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: osdeviceIdvalem 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çagetUserMedia({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 umdeviceIdde verdade pra dizer isso.- Trocar aplica na hora, mesmo com chamada em andamento, sem re-INVITE.
Pode mandar direto do
onChangede um<select>. - Persistir a escolha é responsabilidade do seu sistema, por usuário: reenvie
micId/saidaIdsalvos a cadasoftphone:confignovo (boot ou troca de ramal), senão a escolha se perde a cada reconexão. - Reaja a
softphone:trocaDispositivoRecusadarevertendo o seletor pro valor anterior (guarde o valor anterior antes de aplicar a troca otimisticamente na UI). - Trate
softphone:estado.dispositivoscomo 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.
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
nomedireto nochamar(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
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'→ mandachamarcomnumero+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:
| skin | Nome exibido |
|---|---|
classic-dark | Clássico Escuro (default, visual original) |
classic-light | Clássico Claro |
vibrant-dark | Vibrante Escuro |
vibrant-light | Vibrante Claro |
soft-dark | Suave Escuro |
soft-light | Suave Claro |
- Campo
skinnosoftphone: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 defaultclassic-dark; reconfig comskininvá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 existesoftphone: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
skinsalvo a cadasoftphone:confignovo, 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 oskindo 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_ORIGINSno deploy do SoftphONE. - Guardar a URL do SoftphONE em config/env, nunca hardcoded.
- Embutir o iframe
?embedded=1comallow="microphone; autoplay", sempre montado. - Handshake
softphone:ready→softphone:configcom 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:estadoa 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/comandotrocarSkin(seção 13).