Moraviq Voice para desenvolvedoresAPI v2

Primeira chamada em 15 minutos

Do zero até uma chamada de voz de verdade entre a portaria (Angular) e um morador (navegador), no sandbox. Você vai: subir o sandbox, gerar as chaves do seu backend, cadastrá-las, fazer as primeiras requisições e ligar.

Piloto. O sandbox hospedado (https://sandbox.<host>) ainda não está no ar; o passo 1 sobe o mesmo código na sua máquina. Quando ele estiver, pule o passo 1, troque VOICE_API pelo endereço dele e cadastre as chaves no console em vez do passo 3.

Antes de começar

  1. Suba o sandbox (2 min)

    git clone https://github.com/idosreisjunior/moraviq-voice.git
    cd moraviq-voice
    bash exemplos/sandbox-local/subir.sh

    Sobe o Postgres, o TURN (coturn) e a voice-api em modo sandbox em http://127.0.0.1:8221. No fim ele mostra o aud que os seus JWT precisam ter: http://127.0.0.1:8221/v2. Para parar: bash exemplos/sandbox-local/subir.sh parar.

  2. Gere as chaves do seu backend (1 min)

    O Voice não emite token para você: o seu backend assina os JWT com a chave privada dele, e o Voice confere com a pública. Uma chave para o servidor (EdDSA aqui) e outra para os tokens de aparelho (ES256 aqui). Em produção, guarde a privada no seu cofre de segredos.

    export PARCEIRO_CHAVES="$PWD/exemplos/portaria-angular/backend/.chaves"
    node exemplos/portaria-angular/backend/chaves.mjs "$PARCEIRO_CHAVES"
    chaves.mjsno repositório
    // Gera as chaves do parceiro de teste: EdDSA para o servidor, ES256 para os
    // tokens de aparelho (como um backend em Java faria). As PRIVADAS ficam em
    // <dir>/*.priv.json (nunca entram no Git nem saem do backend); as PÚBLICAS em
    // <dir>/*.pub.json, para colar no console (ou no cadastrar-parceiro.sh).
    //
    //   node chaves.mjs [dir]       (padrão: ./.chaves; não sobrescreve)
    import { generateKeyPairSync } from 'node:crypto';
    import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
    import { join } from 'node:path';
    
    const dir = process.argv[2] || process.env.PARCEIRO_CHAVES || '.chaves';
    mkdirSync(dir, { recursive: true, mode: 0o700 });
    
    function par(nome, kid, tipo, opcoes) {
      const priv = join(dir, `${nome}.priv.json`);
      if (existsSync(priv)) { console.log(`${priv} já existe, mantido`); return; }
      const { publicKey, privateKey } = generateKeyPairSync(tipo, opcoes);
      writeFileSync(priv, JSON.stringify({ ...privateKey.export({ format: 'jwk' }), kid }), { mode: 0o600 });
      writeFileSync(join(dir, `${nome}.pub.json`), JSON.stringify({ ...publicKey.export({ format: 'jwk' }), kid }, null, 2));
      console.log(`${nome}: kid ${kid}, pública em ${join(dir, `${nome}.pub.json`)}`);
    }
    
    par('servidor', 'srv-1', 'ed25519');
    par('cliente', 'cli-1', 'ec', { namedCurve: 'P-256' });
  3. Cadastre o emissor e as chaves públicas (1 min)

    No sandbox hospedado isto é o console (Chaves e webhooks). No local:

    export PARCEIRO_ISS=https://condo-teste.exemplo
    bash exemplos/sandbox-local/cadastrar-parceiro.sh condo-teste "$PARCEIRO_ISS" \
      "$PARCEIRO_CHAVES/servidor.pub.json" "$PARCEIRO_CHAVES/cliente.pub.json"

    O iss é o nome do seu emissor (qualquer URL sua); cada chave tem um kid, um uso (server ou client) e um algoritmo. Só a chave pública sai do seu backend.

  4. Primeira requisição: o condomínio e a guarita (2 min)

    Toda rota de servidor leva no Authorization uma asserção nova: um JWT de 50 s, de uso único, com os escopos da operação.

    export VOICE_API=http://127.0.0.1:8221
    node docs-portal/exemplos/node/01-site-e-posto.mjs
    assinatura.mjsno repositório
    // Assinatura dos JWT do parceiro para o Moraviq Voice, só com node:crypto.
    //
    // - asserção do servidor (typ voice+server): vida até 60 s, jti de uso único,
    //   scope em texto separado por espaço; vai no Authorization das rotas de servidor.
    // - token de aparelho (typ voice, ver 2): vida até 600 s (use 300), sub e site
    //   são os seus ext_id, dev é o sha256 do installation_id do aparelho.
    //
    // EdDSA (Ed25519) e ES256 (P-256) são os dois algoritmos aceitos; um por kid.
    import { createHash, createPrivateKey, randomUUID, sign } from 'node:crypto';
    
    const b64url = (buf) => Buffer.from(buf).toString('base64url');
    
    /** Assina um JWT compacto com a chave privada em JWK (OKP Ed25519 ou EC P-256). */
    export function assinarJwt(cabecalho, claims, jwkPrivada) {
      const chave = createPrivateKey({ key: jwkPrivada, format: 'jwk' });
      const alg = jwkPrivada.kty === 'OKP' ? 'EdDSA' : 'ES256';
      const h = b64url(JSON.stringify({ ...cabecalho, alg, kid: jwkPrivada.kid }));
      const c = b64url(JSON.stringify(claims));
      const dados = Buffer.from(`${h}.${c}`);
      // ES256 em JWT é r||s (64 bytes), não DER: dsaEncoding ieee-p1363.
      const assinatura = alg === 'EdDSA'
        ? sign(null, dados, chave)
        : sign('sha256', dados, { key: chave, dsaEncoding: 'ieee-p1363' });
      return `${h}.${c}.${b64url(assinatura)}`;
    }
    
    /** Asserção do servidor do parceiro: uma por requisição (o jti não se repete). */
    export function assercaoDoServidor({ iss, aud, scope, chave, vidaS = 50 }) {
      const agora = Math.floor(Date.now() / 1000);
      return assinarJwt({ typ: 'voice+server' }, {
        iss, aud, iat: agora, exp: agora + vidaS, jti: randomUUID(), scope,
      }, chave);
    }
    
    /** Token de aparelho para a portaria ou o morador logado no seu sistema. */
    export function tokenDoAparelho({ iss, aud, sub, site, installationId, scope, grupos = [], chave, vidaS = 300 }) {
      const agora = Math.floor(Date.now() / 1000);
      return assinarJwt({ typ: 'voice' }, {
        iss, aud, ver: 2, sub, site,
        dev: createHash('sha256').update(installationId).digest('hex'),
        scope, grp: grupos, jti: randomUUID(), iat: agora, nbf: agora, exp: agora + vidaS,
      }, chave);
    }
    01-site-e-posto.mjsno repositório
    // Passo 1: cadastra o condomínio (site) e a guarita (posto). Idempotente.
    import { assercaoDoServidor } from './assinatura.mjs';
    import { API, AUD, ISS, chave } from './comum.mjs';
    
    const servidor = chave('servidor');
    const auth = () => ({
      Authorization: `Bearer ${assercaoDoServidor({ iss: ISS, aud: AUD, scope: 'sites:write', chave: servidor })}`,
      'Content-Type': 'application/json',
    });
    
    let r = await fetch(`${API}/v2/sites/cond_jardins`, {
      method: 'PUT', headers: auth(), body: JSON.stringify({ enabled: true, ring_timeout_s: 30 }),
    });
    if (!r.ok) throw new Error(`site: ${r.status} ${await r.text()}`);
    console.log('site', await r.json());
    
    r = await fetch(`${API}/v2/sites/cond_jardins/stations/guarita_1`, {
      method: 'PUT', headers: auth(), body: JSON.stringify({ label: 'Guarita 1' }),
    });
    if (!r.ok) throw new Error(`posto: ${r.status} ${await r.text()}`);
    console.log('posto', await r.json());
  5. Token de aparelho (1 min)

    Quando a pessoa entra no seu app (portaria ou morador), o seu backend entrega a ela um token de aparelho de 5 minutos. O SDK pede um novo pelo getToken sozinho.

    node docs-portal/exemplos/node/02-token-de-aparelho.mjs
    02-token-de-aparelho.mjsno repositório
    // Passo 2: o token de aparelho que o SEU backend entrega ao app (portaria ou
    // morador), assinado com a chave client (aqui ES256). Prova: o Voice aceita e
    // devolve um ticket de WebSocket.
    import { randomUUID } from 'node:crypto';
    import { tokenDoAparelho } from './assinatura.mjs';
    import { API, AUD, ISS, chave } from './comum.mjs';
    
    const token = tokenDoAparelho({
      iss: ISS, aud: AUD,
      sub: 'res_77',                 // o ext_id da pessoa no seu sistema
      site: 'cond_jardins',          // um site já cadastrado
      installationId: `inst-${randomUUID()}`, // um por app instalado; vai como sha256 no claim dev
      scope: ['call:receive', 'call:answer'],
      chave: chave('cliente'),
    });
    
    const r = await fetch(`${API}/v2/ws-ticket`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
    if (r.status !== 200) throw new Error(`ws-ticket: ${r.status} ${await r.text()}`);
    const { expires_in: vida } = await r.json();
    console.log(`token aceito; ticket de uso único válido por ${vida} s`);
  6. Suba a portaria Angular e o morador de exemplo (5 min)

    (cd sdk/angular && npm ci && npm run build)
    cd exemplos/portaria-angular
    npm ci && npm run build
    VOICE_API=$VOICE_API PARCEIRO_ISS=$PARCEIRO_ISS PARCEIRO_CHAVES=$PARCEIRO_CHAVES npm run backend

    O backend fica rodando neste terminal. Para os comandos dos próximos passos, abra outro terminal na raiz do repositório e repita os três export (PARCEIRO_CHAVES, PARCEIRO_ISS, VOICE_API).

    O backend de exemplo (backend/servidor.mjs) é o seu backend em miniatura: emite os tokens e cria a chamada. A portaria é Angular com @moraviq/voice-angular; o morador é uma página com o SDK JS.

  7. Ligue (1 min)

    1. Abra http://127.0.0.1:8222/morador/?usuario=res_77 numa janela. Ela registra o aparelho e fica esperando (conexão: open).
    2. Abra http://127.0.0.1:8222/ em outra janela (ou em outro navegador), deixe a unidade A-304 e o morador res_77 e clique em Ligar. Permita o microfone.
    3. Na janela do morador aparece "Guarita 1 está chamando": Atender. Pronto: vocês estão falando.

    O que aconteceu: o backend chamou POST /v2/calls com callees: ["res_77"]; a portaria entrou com attachOutgoing(call_id); o morador recebeu incomingCall pelo WebSocket e atendeu; a portaria mandou a offer depois do aceite; o áudio passou direto (ou pelo TURN, se a rede pedir; force com ?relay=1 nas duas janelas).

    servidor.mjsno repositório
        if (req.method === 'POST' && req.url === '/api/portaria/chamar') {
          const { porteiro, unidade, moradores, motivo } = await corpoJson(req);
          const view = await voz('POST', '/v2/calls', {
            site: SITE,
            target: { kind: 'unit', ref: unidade },
            callees: moradores,
            caller: { user: porteiro, station: POSTO },
            context: { reason: motivo || 'visitor' },
            labels: { target: `Unidade ${unidade}` },
            metadata: { origem: 'portaria-angular' },
          }, { scope: 'calls:create', idempotencia: `chamar-${randomUUID()}` });
          return json(res, 201, { call_id: view.id, status: view.status, devices_ringing: view.devices_ringing });
        }
  8. Depois da chamada

    Na raiz do repositório: consulte as chamadas do site (GET /v2/calls?site=cond_jardins, escopo calls:read), registre o desfecho (POST /v2/calls/{call_id}/disposition) e receba os webhooks. No sandbox local o webhook não sai para localhost (o Voice recusa endereço privado, regra de segurança); teste a sua conferência com docs-portal/exemplos/node/04-webhook.mjs.

    03-ligar.mjsno repositório
    // Passo 3: o backend cria a chamada (modo prior: VOCÊ diz quem toca, em callees).
    // Com o morador da página de exemplo aberto, o aparelho dele toca; aqui o
    // exemplo desliga logo em seguida (sem portaria, ela terminaria em origin_gone).
    import { randomUUID } from 'node:crypto';
    import { assercaoDoServidor } from './assinatura.mjs';
    import { API, AUD, ISS, chave } from './comum.mjs';
    
    const servidor = chave('servidor');
    const h = (scope, extra = {}) => ({
      Authorization: `Bearer ${assercaoDoServidor({ iss: ISS, aud: AUD, scope, chave: servidor })}`,
      'Content-Type': 'application/json',
      ...extra,
    });
    
    const idem = `pedido-${randomUUID()}`; // mesma chave = mesma chamada, mesmo se você repetir o POST
    const corpo = {
      site: 'cond_jardins',
      target: { kind: 'unit', ref: 'A-304' },
      callees: ['res_77'],
      caller: { user: 'port_12', station: 'guarita_1' },
      context: { reason: 'delivery', reason_label: 'Mercado' },
      metadata: { pedido: 'ped_123' }, // volta no webhook; nunca vai a aparelho
    };
    let r = await fetch(`${API}/v2/calls`, { method: 'POST', headers: h('calls:create', { 'Idempotency-Key': idem }), body: JSON.stringify(corpo) });
    if (r.status !== 201) throw new Error(`criar: ${r.status} ${await r.text()}`);
    const chamada = await r.json();
    console.log('criada', chamada.id, chamada.status, chamada.end_reason ?? '');
    
    // Repetir com a mesma chave devolve a mesma chamada (200), sem criar outra.
    r = await fetch(`${API}/v2/calls`, { method: 'POST', headers: h('calls:create', { 'Idempotency-Key': idem }), body: JSON.stringify(corpo) });
    if (r.status !== 200 || (await r.json()).id !== chamada.id) throw new Error('idempotência');
    
    r = await fetch(`${API}/v2/calls/${chamada.id}`, { headers: h('calls:read') });
    console.log('agora', (await r.json()).status);
    
    r = await fetch(`${API}/v2/calls/${chamada.id}/hangup`, { method: 'POST', headers: h('calls:end') });
    const fim = await r.json();
    console.log('fim', fim.status, fim.end_reason);

Ao terminar

Ctrl+C no terminal do backend e bash exemplos/sandbox-local/subir.sh parar.

Se algo der errado

SintomaCausa provável
401 unauthorized em tudoaud diferente do que o subir.sh mostrou, iss diferente do cadastrado, relógio da máquina adiantado ou kid não cadastrado. O 401 é seco de propósito: confira um por um.
401 só no token de aparelhosite ainda não cadastrado (passo 4) ou scope fora de call:receive call:answer call:place.
"Não foi possível ligar (mic_denied)"o navegador negou o microfone; libere para 127.0.0.1:8222.
Toca e cai sozinha com origin_gonea portaria precisa entrar com attachOutgoing em até 10 s depois de criada.
Conecta mas sem somo navegador bloqueou o autoplay: o componente mostra "Ativar o som".
Porta ocupadaSANDBOX_PORTA_API, SANDBOX_PORTA_TURN e SANDBOX_PORTA_APP mudam as portas (e o aud).

Os mesmos passos em Python e cURL

primeira_chamada.pyno repositório
"""O backend do parceiro em Python (PyJWT): asserção do servidor com EdDSA, site, chamada e fim.

pip install "pyjwt[crypto]" httpx
VOICE_API=... PARCEIRO_ISS=... PARCEIRO_CHAVES=.chaves python primeira_chamada.py
"""

import json
import os
import time
import uuid
from pathlib import Path

import httpx
import jwt

API = os.environ.get("VOICE_API", "http://127.0.0.1:8221").rstrip("/")
AUD = os.environ.get("VOICE_AUD", f"{API}/v2")
ISS = os.environ.get("PARCEIRO_ISS", "https://condo-teste.exemplo")
CHAVES = Path(os.environ.get("PARCEIRO_CHAVES", ".chaves"))


def chave_privada(nome: str):
    jwk = json.loads((CHAVES / f"{nome}.priv.json").read_text())
    alg = "EdDSA" if jwk["kty"] == "OKP" else "ES256"
    return jwt.PyJWK(jwk, algorithm=alg), alg


SERVIDOR, ALG = chave_privada("servidor")


def assercao(scope: str) -> str:
    """Uma por requisição: vida até 60 s e jti de uso único."""
    agora = int(time.time())
    claims = {
        "iss": ISS,
        "aud": AUD,
        "iat": agora,
        "exp": agora + 50,
        "jti": uuid.uuid4().hex,
        "scope": scope,
    }
    return jwt.encode(
        claims, SERVIDOR.key, algorithm=ALG, headers={"kid": SERVIDOR.key_id, "typ": "voice+server"}
    )


def h(scope: str, **extra) -> dict:
    return {"Authorization": f"Bearer {assercao(scope)}"} | extra


with httpx.Client(base_url=API, timeout=10) as c:
    r = c.put("/v2/sites/cond_jardins", json={"enabled": True}, headers=h("sites:write"))
    r.raise_for_status()
    corpo = {
        "site": "cond_jardins",
        "target": {"kind": "unit", "ref": "A-304"},
        "callees": ["res_77"],
        "caller": {"user": "port_12", "station": "guarita_1"},
        "context": {"reason": "visitor"},
    }
    chave = f"pedido-{uuid.uuid4()}"
    r = c.post("/v2/calls", json=corpo, headers=h("calls:create", **{"Idempotency-Key": chave}))
    assert r.status_code == 201, r.text
    chamada = r.json()
    print("criada", chamada["id"], chamada["status"], chamada.get("end_reason") or "")
    r = c.post(f"/v2/calls/{chamada['id']}/hangup", headers=h("calls:end"))
    print("fim", r.json()["status"], r.json()["end_reason"])

    # Erro tem forma fixa: {"error", "detail"?, "request_id"} (o mesmo do X-Request-Id).
    r = c.get(f"/v2/calls/{uuid.uuid4()}", headers=h("calls:read"))
    erro = r.json()
    assert r.status_code == 404 and erro["error"] == "call_not_found"
    assert erro["request_id"] == r.headers["X-Request-Id"]
    print("erro esperado", erro["error"], erro["request_id"])
primeira-chamada.shno repositório
#!/usr/bin/env bash
# A mesma chamada com cURL. A asserção é um JWT que o SEU backend assina (aqui,
# o exemplo Node imprime uma); cada uma vale 50 s e serve para UMA requisição.
set -euo pipefail
API="${VOICE_API:-http://127.0.0.1:8221}"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../node" && pwd)"
assercao() { node "$DIR/assercao.mjs" "$1"; }

curl -fsS -X POST "$API/v2/calls" \
  -H "Authorization: Bearer $(assercao calls:create)" \
  -H "Idempotency-Key: pedido-$(date +%s%N)" \
  -H "Content-Type: application/json" \
  -d '{"site":"cond_jardins","target":{"kind":"unit","ref":"A-304"},"callees":["res_77"],
       "caller":{"user":"port_12","station":"guarita_1"},"context":{"reason":"visitor"}}' \
  -o chamada.json
ID=$(python3 -c 'import json; print(json.load(open("chamada.json"))["id"])')
echo "criada $ID"
curl -fsS -X POST "$API/v2/calls/$ID/hangup" -H "Authorization: Bearer $(assercao calls:end)"
echo
rm -f chamada.json

Todos os exemplos desta página rodam no CI contra o sandbox, a cada mudança (docs-portal/exemplos/rodar.sh). Exemplo velho derruba o CI.