Onvox AI Docs

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.

GatilhoOrigem da análise concluídaNo destino recém-criado
manual_uiUpload de áudio ou texto colado pela tela Nova Análisemarcado
external_apiAnálise criada via POST /api/analyses (com X-API-Key)marcado
scheduled_collectionGravaçã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):

CampoO que éQuando usar
data.salespersonIdId do vendedor na plataformaChave estável para casar com o seu cadastro
data.salespersonNameNome do vendedorRotear ou rotular sem depender de id
data.externalOwnerIdRamal dono da chamada no PABXRotear por atendente sem depender do cadastro daqui
data.externalCallIdId 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ídaCondição
Time A{{ $json.data.externalOwnerId }} está em 1018, 1019, 1020
Time B{{ $json.data.externalOwnerId }} está em 1030, 1031
Restofallback

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.

PapelEscopo do data
admin_company, super_adminDossiê 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:

CampoO que éQuando usar
analysisIdChave da análise na plataformaGuardar no seu sistema; é o que GET /api/analyses/{id} aceita
data.externalCallIdId da chamada no PABX (o mesmo call_id)Casar o evento com o CDR do seu lado
data.externalOwnerIdRamal dono da chamadaRotear por atendente sem depender do cadastro daqui
data.onvox.record_fileNome do arquivo de gravação no PABXLocalizar 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", 200

Compare 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.

TentativaAtraso 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çãoEfeito
Editar nome e targetUrlAjusta 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 gatilhosAjusta 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 segredoGera 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.
ApagarRemove 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_company e super_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 targetUrl passar 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.

Próximos passos

Nesta página