Moraviq Voice para desenvolvedoresAPI v2

Webhooks

O Voice avisa o seu backend do que aconteceu com cada chamada, no padrão Standard Webhooks: você confere a assinatura com a biblioteca oficial de qualquer linguagem.

Cadastro

No console (sandbox local: tools/parceiro.py webhook <slug> https://… --eventos …). O segredo whsec_… aparece uma vez. O endereço tem de ser HTTPS na porta 443 e resolver para IP público: o Voice recusa endereço privado, loopback e redirecionamento (o sandbox local, portanto, não entrega para localhost).

Eventos

TipoQuando
call.ringingcomeçou a tocar (com devices_ringing)
call.unconfirmedtocando há 10 s sem nenhum aparelho confirmar o toque (uma vez): hora do seu plano B
call.answeredalguém atendeu
call.finishedqualquer estado terminal, inclusive a chamada que já nasce encerrada
{"type": "call.finished", "timestamp": "2026-11-14T12:00:31Z", "data": {
  "call_id": "…", "site": "cond_jardins", "target": {"kind": "unit", "ref": "A-304"},
  "caller": {"user": "port_12", "station": "guarita_1"}, "answered_by": "res_77",
  "status": "completed", "end_reason": "normal", "disposition": "allowed", "network": null,
  "started_at": "…", "answered_at": "…", "ended_at": "…", "duration_s": 42,
  "idempotency_key": "pedido-123", "metadata": {"pedido": "ped_123"}}}

Sem nome, rótulo, IP, aparelho ou token. A ordem de chegada não é garantida: use timestamp e status.

Assinatura

Confira sobre o corpo cru, antes de converter de JSON, e recuse fora de 5 minutos.

04-webhook.mjsno repositório
/**
 * @param {string} segredo  whsec_... (mostrado uma vez no cadastro)
 * @param {Record<string,string>} cabecalhos  webhook-id, webhook-timestamp, webhook-signature
 * @param {string} corpo  o corpo CRU, exatamente como chegou (antes de JSON.parse)
 */
export function verificarWebhook(segredo, cabecalhos, corpo, { toleranciaS = 300, agoraS = Math.floor(Date.now() / 1000) } = {}) {
  const id = cabecalhos['webhook-id'];
  const ts = Number(cabecalhos['webhook-timestamp']);
  if (!id || !Number.isFinite(ts) || Math.abs(agoraS - ts) > toleranciaS) throw new Error('webhook fora do prazo');
  const chave = Buffer.from(segredo.replace(/^whsec_/, ''), 'base64');
  const esperada = createHmac('sha256', chave).update(`${id}.${ts}.${corpo}`).digest();
  // Na rotação vêm duas assinaturas separadas por espaço: vale qualquer uma.
  const ok = (cabecalhos['webhook-signature'] || '').split(' ').some((par) => {
    const [versao, b64] = par.split(',');
    const dada = Buffer.from(b64 || '', 'base64');
    return versao === 'v1' && dada.length === esperada.length && timingSafeEqual(dada, esperada);
  });
  if (!ok) throw new Error('assinatura inválida');
  return JSON.parse(corpo);
}
webhook.pyno repositório
def receber(segredo: str, cabecalhos: dict, corpo: bytes) -> dict:
    # Corpo CRU, antes de qualquer json.loads: a assinatura é sobre os bytes.
    return Webhook(segredo).verify(corpo, cabecalhos)

Entrega e novas tentativas