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
| Tipo | Quando |
|---|---|
call.ringing | começou a tocar (com devices_ringing) |
call.unconfirmed | tocando há 10 s sem nenhum aparelho confirmar o toque (uma vez): hora do seu plano B |
call.answered | alguém atendeu |
call.finished | qualquer 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
webhook-id:evt_…, o mesmo em todas as tentativas (deduplique por ele).webhook-timestamp: o desta tentativa, em segundos.webhook-signature:v1,<base64(HMAC-SHA256(segredo, "{id}.{timestamp}.{corpo}"))>. Na rotação, o segredo novo e o antigo valem juntos por 24 h e vêm as duas assinaturas, separadas por espaço.
Confira sobre o corpo cru, antes de converter de JSON, e recuse fora de 5 minutos.
/**
* @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);
}
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
- Responda
2xxem até 5 s (processe depois, numa fila sua). Redirecionamento conta como falha. O corpo da sua resposta é lido até 4 KB e descartado. - 8 tentativas: a primeira e mais 7, depois de 5 s, 30 s, 2 min, 15 min, 1 h, 6 h e 16 h (com até 10% de variação). Somando, cerca de 23 h entre a primeira e a última.
- Falhou a oitava: o evento vai para a fila morta por 7 dias, com o corpo e a última resposta; reenvie pelo console.
- Endpoint falhando há 72 h fica pausado até você reenviar.
- Até 4 entregas simultâneas por conta.