Moraviq Voice para desenvolvedoresAPI v2

Autenticação

Não há senha nem segredo compartilhado com o Voice. O seu backend tem duas chaves privadas (uma por uso) e assina JWT; o Voice confere com as chaves públicas que você cadastrou. Tudo vai em Authorization: Bearer, nunca em URL.

Chaves

UsoAssinaQuem guarda a privada
servera asserção do servidor (rotas de servidor)só o seu backend
clientos tokens de aparelho que o seu backend entrega aos appssó o seu backend (o app recebe o token, nunca a chave)

Rotação sem parar

  1. cadastre o kid novo;
  2. marque o velho como rotating (ele continua valendo) e passe a assinar com o novo;
  3. marque o velho como revoked: ele para de valer na próxima requisição.

Asserção do servidor

Uma por requisição. Cabeçalho {"typ": "voice+server", "alg", "kid"}.

ClaimValor
isso seu emissor cadastrado
audhttps://api.voice.moraviq.com.br/v2 (no sandbox, o que o console ou o subir.sh mostram)
iat, expvida de até 60 s (use 50)
jti8 a 128 caracteres, uso único: repetir dá 401
scopetexto separado por espaço, só o que a rota pede: sites:write, calls:create, calls:read, calls:end, devices:revoke, subjects:manage

Token de aparelho

O seu backend entrega à pessoa logada (portaria ou morador). Cabeçalho {"typ": "voice", "alg", "kid"} com o kid de uso client.

ClaimValor
ver2
iss, audcomo na asserção
subo ext_id da pessoa no seu sistema (até 64 de [A-Za-z0-9._:-])
siteo ext_id de um site já cadastrado
devsha256(installation_id) em hex: amarra o token ao app instalado
scopelista: call:receive, call:answer, call:place
grpaté 20 grupos (ext_id); guardado, ainda sem efeito
jti, iat, nbf, expvida de até 600 s (use 300); o SDK renova a 30 s do fim e no 401
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);
}

Python (PyJWT)

primeira_chamada.pyno repositório
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

Credencial do aparelho e token curto da chamada

App fechado acordado por push precisa atender sem esperar o seu backend. Para isso, POST /v2/devices devolve um device_secret (32 bytes em base64url) que o SDK nativo guarda no Keychain ou no Keystore. Com ele, o aparelho troca o call_id do push por um token de 120 s que só vale para aquela chamada:

POST /v2/calls/{call_id}/device-token     (sem Authorization)
{"device_id": "...", "ts": 1763000000, "nonce": "<hex de 16 a 64>",
 "proof": hex(HMAC-SHA256(base64url_decode(device_secret), "v2\n{call_id}\n{ts}\n{nonce}"))}

200 {"token": "vct1.…", "expires_in": 120}

Revogação e kill switch