Webhooks
Receba um POST assinado quando uma análise é concluída — UI, API externa ou coleta de integração (esta última configurável por destino) —, com payload reduzido por papel, verificação de assinatura HMAC-SHA256 e política de retry.
Webhooks
A Onvox AI pode notificar seu sistema via POST assinado quando uma análise é concluída — seja via upload de áudio/texto colado na UI, via POST /api/analyses (API externa, pra quem integra por código e quer o resultado de volta assim que fica pronto), ou vinda de uma coleta de integração (PABX Onvox/Yeastar, Google Meet). Essa terceira origem é configurável por destino, via os gatilhos descritos em Quando o webhook dispara. Cada destino é atrelado a um papel (role), que define quais campos o payload carrega. Os papéis disponíveis no cadastro são os de administrador, então o destino recebe o dossiê completo, no escopo da empresa inteira — ver Por que só papel de administrador. Quantos destinos você cadastra é com você: não há teto.
Quando o webhook dispara
Quem decide é a sua empresa, destino a destino. Cada webhook carrega uma lista de gatilhos, e o evento só é enviado para os destinos que aceitam a origem daquela análise.
| Gatilho | Origem da análise concluída | No destino recém-criado |
|---|---|---|
manual_ui | Upload de áudio ou texto colado pela tela Nova Análise | marcado |
external_api | Análise criada via POST /api/analyses (com X-API-Key) | marcado |
scheduled_collection | Gravação coletada de uma integração — Onvox/Yeastar ou Google Meet, tanto pelo polling automático quanto pelo clique em "Coletar agora" | desmarcado |
Um webhook novo nasce com os dois primeiros marcados. Esse é o comportamento que a plataforma sempre teve, e ele foi preservado: nenhum destino já existente passou a receber coisa nova sem alguém marcar.
Quer receber as gravações coletadas do PABX? Você precisa MARCAR — não vem ligado.
O gatilho scheduled_collection vem desmarcado, em destino novo e nos que já existiam. Isso é deliberado: nenhuma empresa passa a receber o movimento inteiro da telefonia sem ter pedido.
Para ligar, em Configurações → Webhooks de Análise:
- destino novo: ao criar, escolha papel admin e marque "Coleta de integração"
- destino que já existe: use Editar gatilhos direto na lista e marque a opção — não apague e recrie, porque o segredo só é exibido uma vez na criação e recriar abriria um intervalo sem entrega
Enquanto essa caixa estiver desmarcada, análises vindas de coleta não geram evento — mesmo que tudo o mais esteja configurado.
O gatilho scheduled_collection só existe em webhooks de papel admin_company ou super_admin. Não é uma restrição da tela — é regra de comportamento, verificada nas duas pontas: a criação é recusada, e o disparo também é bloqueado mesmo que a configuração seja gravada por fora do painel.
O motivo é escopo. Uma análise coletada do PABX pertence à empresa inteira, e o papel de um webhook reduz campos, não linhas — um destino de vendedor receberia o movimento de todos os vendedores. Enquanto o escopo por linha não existir, o gatilho de coleta fica onde papel e visibilidade já coincidem.
Hoje todo destino cadastrável já é de papel administrador (ver Por que só papel de administrador), então esta regra só chega a valer para destino cadastrado antes dessa mudança.
"Coletar agora" conta como coleta. Clicar no botão é uma ação manual sua, mas internamente esse fluxo é o mesmo da coleta agendada — a plataforma não distingue um do outro. Os dois caem em scheduled_collection. Se você quer ser notificado ao clicar em "Coletar agora", marque esse gatilho; e saiba que ele traz junto a coleta automática.
Configurar um webhook
Configuração é restrita a admin_company e super_admin, na tela Configurações → Webhooks de Análise do painel. Uma empresa pode cadastrar quantos destinos quiser, inclusive vários no mesmo papel — é comum ter dois admin_company, um apontando para o BI interno e outro para o n8n. Não há teto de quantidade; há teto de papel: só os dois de administrador podem ser cadastrados, pelo motivo explicado em Por que só papel de administrador.
O único cadastro recusado é o duplicado exato: mesma empresa, mesmo papel e mesma URL. Dois destinos idênticos receberiam o mesmo conteúdo duas vezes, sem ganho nenhum. A mesma URL em papéis diferentes é permitida (ela receberia dois corpos diferentes, um por papel), e nada impede vários destinos do mesmo papel apontando para URLs diferentes.
Ao criar ou editar um destino você escolhe:
- o nome (opcional, até 80 caracteres), só para diferenciar destinos do mesmo papel na lista. Em branco, a tela mostra o endereço do destino no lugar;
- o papel, que define quais campos o payload carrega (ver Payload por papel);
- os gatilhos, que definem quais análises chegam ali (ver Quando o webhook dispara).
Os gatilhos de coleta ficam desabilitados enquanto o papel escolhido não for de administrador, pela razão explicada acima.
Diferente do resto desta documentação, analysisWebhooks.create/update/delete/regenerateSecret/list não aceitam X-API-Key: são rotas do tipo adminProcedure (sessão de navegador do better-auth), pensadas para serem operadas pela UI, não por integração externa. Chamá-los apenas com o header X-API-Key devolve 401 Unauthorized. O efeito do webhook (o POST que a Onvox AI envia para o SEU targetUrl) é a parte que integra com o sistema externo; a configuração em si é tela.
Resposta da tela ao criar (mesmo contrato do tRPC; o segredo só aparece AGORA, nunca mais):
{
"result": {
"data": {
"id": "wh_abc123",
"role": "admin_company",
"label": "n8n produção",
"triggers": ["manual_ui", "external_api"],
"targetUrl": "https://seusistema.com/webhooks/onvox",
"isActive": true,
"createdAt": "2026-09-18T12:00:00.000Z",
"updatedAt": "2026-09-18T12:00:00.000Z",
"secret": "8f1e2a9c...64 chars hex (guarde agora)"
}
}
}targetUrl exige https://; destinos http:// são rejeitados na criação. A URL também passa por uma checagem de SSRF: hostnames que resolvam (DNS) para IP privado/loopback/link-local são recusados.
Por que só papel de administrador
Os papéis oferecidos no cadastro são os dois de administrador (admin_company e super_admin). Um destino com papel de vendedor ou supervisor é recusado.
O papel de um webhook reduz os campos do payload, não as linhas. Um destino de vendedor ou supervisor receberia as análises de toda a empresa — inclusive de gente que esse mesmo usuário não enxerga pela tela. Enquanto não existir escopo por linha, o destino fica onde papel e visibilidade já coincidem: o escopo da empresa.
Quantidade é outra coisa. Não há teto de destinos: cadastre quantos precisar, inclusive vários no mesmo papel. O que não existe é destino de uma pessoa.
Preciso separar por vendedor, time ou unidade
Isso o webhook não faz: todo destino recebe as análises da empresa inteira. A separação acontece no seu próprio fluxo (n8n, Make, sua API, seu backend) — e você não perde informação nenhuma, porque o evento já diz de quem é a análise. Se o objetivo for só mandar o mesmo evento para dois sistemas, aí não precisa de nada disso: cadastre dois destinos.
Estes campos viajam em todo evento, para todo destino de papel administrador (um destino legado de vendedor ou supervisor recebe só data.salespersonName; os outros três não existem no payload dele):
| Campo | O que é | Quando usar |
|---|---|---|
data.salespersonId | Id do vendedor na plataforma | Chave estável para casar com o seu cadastro |
data.salespersonName | Nome do vendedor | Rotear ou rotular sem depender de id |
data.externalOwnerId | Ramal dono da chamada no PABX | Rotear por atendente sem depender do cadastro daqui |
data.externalCallId | Id da chamada no PABX (o mesmo call_id) | Casar o evento com o CDR do seu lado |
Concretamente. O POST que chega no seu endpoint tem esta cara (dossiê abreviado, os campos de roteamento em destaque):
{
"event": "analysis.completed",
"analysisId": "an_9f8e7d6c",
"role": "admin_company",
"data": {
"id": "an_9f8e7d6c",
"status": "completed",
"source": "onvox",
"displayScore": 87,
"summary": "Cliente demonstrou interesse, mas pediu prazo para decidir.",
"salespersonId": "user_123",
"salespersonName": "Helena Pereira",
"externalOwnerId": "1018",
"externalCallId": "1784291064.69"
}
}Daí em diante a separação é sua. Em n8n, um nó Switch com uma saída por regra:
| Saída | Condição |
|---|---|
| Time A | {{ $json.data.externalOwnerId }} está em 1018, 1019, 1020 |
| Time B | {{ $json.data.externalOwnerId }} está em 1030, 1031 |
| Resto | fallback |
Ou, se preferir código, no seu próprio handler:
const DESTINOS = {
user_123: 'https://crm.interno/equipe-a',
user_456: 'https://crm.interno/equipe-b',
};
app.post('/webhooks/onvox', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
verifyOnvoxAiWebhook(/* ... ver "Verificar a assinatura" abaixo ... */);
const { data } = JSON.parse(rawBody);
const destino = DESTINOS[data.salespersonId]; // ou data.externalOwnerId
if (destino) {
encaminhar(destino, data);
}
res.status(200).send('ok');
});Precisa de algo diferente disso? Um destino por vendedor ou roteamento por pessoa feito do nosso lado é customização: fale com o nosso desenvolvimento e a gente avalia o que dá pra fazer no seu caso. Vários destinos, isso a plataforma já faz — é só cadastrar.
O evento: analysis.completed
Todo POST carrega o mesmo envelope:
{
"event": "analysis.completed",
"analysisId": "an_9f8e7d6c",
"role": "admin_company",
"data": { "...": "payload escopado pelo papel (ver abaixo)" }
}Payload por papel
O campo data usa exatamente o mesmo contrato de lib/analysis-output.ts já usado pela API (analysis.getPublic vs analysis.getById).
A equivalência com "o que aquele papel veria numa chamada autenticada" vale para os campos — o formato do dossiê é o mesmo. Ela não vale para quais análises chegam ali: o destino pertence à empresa, não a uma pessoa, e recebe todas as análises concluídas da empresa que casem com os gatilhos configurados. Como o papel de um destino é sempre de administrador, escopo de campo e escopo de linha coincidem: dossiê completo, empresa inteira.
| Papel | Escopo do data |
|---|---|
admin_company, super_admin | Dossiê completo + tokensUsed, cost, companyId, salespersonId, jobId (observabilidade e custo) + provider, externalCallId, externalOwnerId (identificadores de integração). São os dois papéis que o cadastro oferece. |
supervisor (legado) | Dossiê público + score e attributionStatus. Não é mais possível cadastrar; um destino assim, criado antes da mudança, continua funcionando, editável e removível pela tela. |
salesperson (legado) | Dossiê público: transcrição, resumo, BANT, objeções, diagnóstico, indicadores universais, sem custo, tokens ou ids internos. Mesma observação do supervisor. |
O bloco onvox (telefonia: quem ligou, pra quem, gravação) faz parte do dossiê público (analysisPublicSchema, herdado por todos os papéis): quando a análise que disparou o evento carrega dados de telefonia, data.onvox aparece no payload de qualquer papel, inclusive nos destinos legados. Os identificadores de integração (provider, externalCallId, externalOwnerId) ficam reservados a admin_company/super_admin — que é o papel de todo destino que se pode cadastrar hoje —, junto do resto da observabilidade/custo.
Exemplo prático: os dois destinos de uma empresa
Quando uma análise conclui e dispara o evento, a Onvox AI despacha um POST para CADA destino ativo daquela empresa que aceite aquele gatilho, e cada um recebe uma versão do mesmo evento escopada pelo papel configurado naquele destino, não pelo papel de quem criou a análise.
O papel define o corpo, e só ele: dois destinos do mesmo papel recebem um corpo idêntico, byte a byte. O que muda entre eles é o endereço e a assinatura (cada destino tem o próprio segredo, então vazar o segredo de um não permite forjar entrega para o outro). Isso vale para quantos destinos existirem: o número de destinos afeta quantos POSTs saem, nunca o que cada um enxerga. Como os dois papéis cadastráveis (admin_company e super_admin) recebem o mesmo contrato, na prática todos os destinos de uma empresa recebem o mesmo corpo.
{
"event": "analysis.completed",
"analysisId": "an_9f8e7d6c",
"role": "admin_company",
"data": {
"id": "an_9f8e7d6c",
"status": "completed",
"source": "upload",
"displayScore": 87,
"score": 87,
"attributionStatus": "auto",
"summary": "Cliente demonstrou interesse, mas pediu prazo para decidir.",
"transcript": "...",
"bant": { "budget": "Confirmado", "authority": null, "need": "Sim", "timing": "30 dias" },
"audioUrl": "https://...",
"tokensUsed": 4200,
"cost": 0.42,
"companyId": "comp_456",
"salespersonId": "user_123",
"salespersonName": "Helena Pereira",
"jobId": "job_789"
}
}Um destino legado de papel salesperson/supervisor (criado antes da restrição) não recebe os campos de custo e observabilidade: eles simplesmente não existem naquele payload, por design. Se o seu sistema espera o dossiê completo, confira o papel do destino na tela.
Análise vinda de coleta de integração (gatilho scheduled_collection, portanto papel administrador). Além do dossiê, o data traz os campos de telefonia — é por eles que você correlaciona a análise com a chamada no seu lado.
Exemplo abreviado: para destacar só os campos novos de integração, o JSON abaixo omite os campos de custo/observabilidade. O payload real de admin_company/super_admin também carrega score, attributionStatus, tokensUsed, cost, companyId, salespersonId e jobId, como na tabela Payload por papel:
{
"event": "analysis.completed",
"analysisId": "an_integracao",
"role": "admin_company",
"data": {
"id": "an_integracao",
"source": "onvox",
"provider": "onvox",
"externalCallId": "1784291064.69",
"externalOwnerId": "1018",
"onvox": {
"call_id": "1784291064.69",
"call_from_name": "Helena Pereira",
"call_from_number": "1018",
"call_to": "4532408500",
"record_file": "20260717092426-1784291064.69-1018-4532408500-Outbound.wav"
},
"transcript": "..."
}
}Os identificadores, e para que serve cada um:
| Campo | O que é | Quando usar |
|---|---|---|
analysisId | Chave da análise na plataforma | Guardar no seu sistema; é o que GET /api/analyses/{id} aceita |
data.externalCallId | Id da chamada no PABX (o mesmo call_id) | Casar o evento com o CDR do seu lado |
data.externalOwnerId | Ramal dono da chamada | Rotear por atendente sem depender do cadastro daqui |
data.onvox.record_file | Nome do arquivo de gravação no PABX | Localizar o áudio original |
Numa análise de upload esses campos vêm nulos ou ausentes. O bloco onvox é omitido quando a origem não é telefonia (upload, Google Meet ou API): a chave simplesmente não vem no JSON, nem como null. Escreva o consumidor tolerando a ausência (data.onvox?.record_file), em vez de assumir que todo evento tem bloco de telefonia.
Verificar a assinatura (lado RECEPTOR)
Todo POST carrega três headers:
Content-Type: application/json
X-EvoluAI-Event: analysis.completed
X-EvoluAI-Timestamp: 1755600000
X-EvoluAI-Signature: sha256=8f1e2a9c...A assinatura é HMAC-SHA256 do material "{timestamp}.{corpo_raw}", usando o segredo que você recebeu na criação do webhook. Some o timestamp na verificação para recusar replay de uma entrega antiga capturada por terceiros.
O timestamp é o do enfileiramento, não o do envio. X-EvoluAI-Timestamp e a assinatura são gerados quando a entrega entra na fila e se repetem, idênticos, em todas as tentativas de retry (ver Retry e backoff). Com fila cheia ou retries, a entrega pode chegar bem depois desse timestamp, e uma janela anti-replay curta, como os 5 minutos dos exemplos abaixo, pode recusar um retry legítimo. Use uma janela maior, ou deduplique pelo analysisId junto com o X-EvoluAI-Timestamp, que é o mesmo em todas as tentativas da mesma entrega.
JavaScript (Node.js / Express)
const crypto = require('node:crypto');
function verifyOnvoxAiWebhook(secret, timestamp, rawBody, signatureHeader) {
// rejeita timestamp com mais de 5 minutos (replay)
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) {
throw new Error('Timestamp fora da janela, possível replay');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const received = signatureHeader.replace(/^sha256=/, '');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(received, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error('Assinatura inválida');
}
}
// Express: use express.raw() nesta rota para ter o corpo CRU (não re-parseado)
app.post('/webhooks/onvox', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
verifyOnvoxAiWebhook(
process.env.ONVOX_AI_WEBHOOK_SECRET,
req.header('X-EvoluAI-Timestamp'),
rawBody,
req.header('X-EvoluAI-Signature')
);
const payload = JSON.parse(rawBody);
// ... processa payload.data
res.status(200).send('ok');
});Python (Flask)
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "seu-segredo-aqui"
def verify_onvox_ai_webhook(secret, timestamp, raw_body, signature_header):
if abs(time.time() - float(timestamp)) > 300:
raise ValueError("Timestamp fora da janela, possível replay")
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.{raw_body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
received = signature_header.removeprefix("sha256=")
if not hmac.compare_digest(expected, received):
raise ValueError("Assinatura inválida")
@app.post("/webhooks/onvox")
def receive_webhook():
raw_body = request.get_data(as_text=True)
try:
verify_onvox_ai_webhook(
WEBHOOK_SECRET,
request.headers.get("X-EvoluAI-Timestamp"),
raw_body,
request.headers.get("X-EvoluAI-Signature"),
)
except ValueError:
abort(401)
payload = request.get_json()
# ... processa payload["data"]
return "ok", 200Compare a assinatura sempre com uma função timing-safe (crypto.timingSafeEqual / hmac.compare_digest), nunca com ===/==, que vaza timing e permite adivinhar o segredo byte a byte.
Retry e backoff
Entregas falhas (qualquer resposta que não seja 2xx, inclusive 4xx, além de timeout e erro de rede) são reenfileiradas automaticamente com backoff exponencial, até 5 tentativas. Responda 2xx assim que tiver validado e enfileirado o processamento; não é preciso terminar todo o processamento síncrono dentro do handler.
| Tentativa | Atraso antes desta tentativa |
|---|---|
| 1ª | imediata |
| 2ª | ~5s |
| 3ª | ~10s |
| 4ª | ~20s |
| 5ª | ~40s |
Todas as 5 tentativas acontecem em menos de 2 minutos (backoff exponencial, base 5s), não sendo um retry espaçado ao longo de horas. Depois da 5ª falha, a entrega é descartada sem retry adicional; trate seu endpoint como best-effort e, se precisar de garantia forte, faça polling do dossiê pela API como reconciliação.
O worker nunca segue redirect (redirect: manual) e nunca espera indefinidamente por uma resposta (timeout de 10s); um 3xx ou uma resposta lenta contam como falha e entram no retry.
Gerenciar um webhook existente
Tudo pela mesma tela Configurações → Webhooks de Análise (admin_company/super_admin):
| Ação | Efeito |
|---|---|
Editar nome e targetUrl | Ajusta direto na lista o nome do destino e para onde ele aponta. A URL nova passa pela mesma checagem de HTTPS + SSRF da criação (é literalmente a mesma função) antes de ser salva; se for recusada, nada é gravado e o destino antigo continua valendo. Trocar a URL preserva o segredo já gerado — o HMAC configurado no seu sistema continua valendo, e não há intervalo sem entrega. A URL nova não pode ser igual à de outro destino do mesmo papel. |
| Editar gatilhos | Ajusta direto na lista quais origens disparam esse destino (Nova Análise / API externa / coleta de integração), sem apagar e recriar o webhook — preserva o segredo já gerado (só é exibido uma vez) e não abre intervalo sem entrega. scheduled_collection continua restrito a papel admin_company/super_admin, mesma regra da criação. |
| Desativar (toggle, sem apagar) | isActive: false: a Onvox AI para de despachar POSTs pra esse webhook imediatamente, mas a configuração (incluindo o segredo) fica salva. Reative quando quiser, sem precisar gerar um segredo novo. |
| Regenerar segredo | Gera um segredo novo e invalida o antigo na hora; assinaturas calculadas com o segredo antigo passam a falhar a partir desse ponto. O segredo novo aparece uma vez só, igual na criação. Use se suspeitar que o segredo vazou. |
| Apagar | Remove a configuração por completo. Pra trocar o role de um webhook, apague e crie um novo, pois update não aceita mudar role — e o papel novo tem que ser um dos dois de administrador. |
Não existe hoje uma ação de "enviar evento de teste". O único jeito de validar que um webhook novo está funcionando é criar uma análise de verdade (ex.: colar um texto curto pela tela Nova Análise) e conferir se a entrega chega. Teste antes de apontar pra um sistema de produção.
Limitações conhecidas (declaradas, não escondidas)
- Todo destino é de papel administrador: os papéis oferecidos no cadastro são
admin_companyesuper_admin. Não há destino por vendedor nem por supervisor — ver Por que só papel de administrador. A quantidade é livre: vários destinos, inclusive no mesmo papel. Necessidade diferente disso é customização: fale com o nosso desenvolvimento. - O escopo do destino é a EMPRESA, nunca uma pessoa: o papel reduz os campos do payload, não quais análises chegam. Um destino recebe todas as análises concluídas da empresa que casem com os gatilhos, e o filtro por pessoa é responsabilidade do seu fluxo.
- Sem evento de teste/ping: ver Callout acima. Planeje validar com uma análise real antes de ir pra produção.
- Sem histórico de entregas consultável pela empresa: a Onvox AI não expõe hoje uma tela ou endpoint com o histórico de tentativas/status de cada entrega (sucesso, falha, motivo). Se uma entrega falhar as 5 tentativas e for descartada, não há alerta automático nem registro visível no painel; a forma de perceber é notar que seu sistema não recebeu o evento esperado.
- DNS revalidado a cada tentativa, inclusive nos retries: proteção deliberada contra DNS rebinding (o destino é checado de novo antes de CADA tentativa de entrega, não só na criação do webhook). Efeito colateral: se o DNS do seu
targetUrlpassar a resolver pra um IP privado/loopback depois de configurado (ex.: infraestrutura interna reconfigurada), toda entrega, incluindo os 5 retries, falha silenciosamente na validação de segurança, sem alerta, até esgotar as tentativas e ser descartada. Mantenha o DNS do destino estável, ou reconfigure o webhook se o destino mudar de rede.