Guias
Webhooks
Chamamos o seu URL sempre que algo acontece - uma chamada concluída, uma tentativa falhada, um SMS enviado ou recebido. Defina-o uma vez (PUT /v1/webhook, ver a referência da API REST) e está feito.
Eventos
Seis eventos, cada um com o seu próprio formato do campo data:
call.completed- a chamada terminou, com ou sem agente. As chamadas com agente trazem também uma transcrição (transcript), um resumo (summary) e os dados recolhidos (data).durationSecsestá em segundos e pode ser um número decimal nas chamadas no motor de voz próprio da volai. Uma chamada em que do outro lado está um autómato a repetir a mesma mensagem também chega aqui, comendReason: "caller_loop"(preço0se durou até cinco minutos). Um toque de portão atendido (kind: "ring",ring.outcome: "answered") chega igualmente aqui, com uma exceção descrita no exemplo decall.no_answer. Uma chamada no motor próprio da volai (versão de 15 out. 2026 em diante) traz ainda um blocoenginecomprotectedUtteranceseprotectedUtterancesCount: falas da pessoa que atendeu, da janela da saudação, que o motor descartou de propósito (um "Estou?" ou o nome dela enquanto o agente dizia a primeira frase) - não estão emtranscript, o agente não reagiu a elas; mesmo formato que emGET /v1/calls/{id}, ausente no ElevenLabs e em versões mais antigas do motor.call.failed- a chamada acabou emstatus: "failed"- quer a plataforma de voz a tenha recusado logo, quer a rede tenha falhado. Chega para um agente no motor da volai e no ElevenLabs, para uma ligação direta (bridge), para uma concessão de relay BYO, para um toque de portão (o operador recusou o pedido,kind: "ring") e para uma chamada de teste - exatamente uma vez, sempre. O corpo trazreason(a causa técnica) eendReason. Uma chamada que simplesmente não foi atendida NÃO pertence aqui - tem o seu próprio evento abaixo. Uma chamada que CHEGOU a ligar mas terminou por um erro técnico no nosso motor de voz (endReason: "engine_error") também chega aqui, e o seu corpo traz tambémtranscript/summary/durationSecs/priceHal- qualquer outroreasonomite estes quatro campos. Para uma tal chamada,reasonéengine_stallouengine_error(uma falha do motor), ouunfinishedquando um reinício ou paragem do motor cortou a chamada a meio da conversa (por exemplo durante uma implementação). Uma tal chamada não é faturada (priceHalé0), não tem gravação, esummaryexplica em poucas palavras o que aconteceu. Otranscriptcontém apenas o que foi captado até à interrupção e pode estar vazio (está muitas vezes vazio quando o registo da chamada nunca chegou do motor e fechámos a chamada a partir da lista de chamadas do motor, que não traz transcrição).durationSecsé o tempo aproximado até à interrupção (no máximo 1800 s quando o motor não o conseguiu medir). O blocoenginecom as falas descartadas na janela da saudação está ausente, porque o motor não mediu a janela da saudação de uma chamada interrompida.call.no_answer- uma chamada efetuada tocou e o destinatário não atendeu. Um toque de portão (POST /v1/rings) que o portão recusou, ou que tocou até ao fim do tempo pedido, chega da mesma forma - reconhece-o porkind: "ring", ver abaixo.call.missed- uma chamada recebida ficou sem resposta (uma chamada perdida). Chega também para uma chamada recebida que o motor de voz recusou porque todas as linhas estavam ocupadas (quem ligou não conseguiu falar, ligue-lhe de volta). O registo de uma tal chamada temendReason: "capacity", duração0e preço0; o webhook pode não o trazer quando o operador comunicou primeiro a chamada não atendida, pelo que o leia de forma fiável a partir do registo da chamada (GET /v1/calls/{id}).message.sent- o operador aceitou o SMS para envio - não é um aviso de entrega. O evento é enviado também para um SMS enviado de um número de telemóvel ou de um número para chamadas e SMS; o camposmsNumbertraz o número de que foi enviado e énullpara o remetente partilhado SMSinfo. O relatório de entrega (deliveryStatus) de um SMS enviado de um número de telemóvel, e uma falha posterior (failed, após a qual o saldo é devolvido), não desencadeiam nenhum evento - leia o estado emGET /v1/messages/{id}.message.received- chegou uma mensagem ao seu número de telemóvel (o complemento número de telemóvel) ou ao seu número para chamadas e SMS.datatrazid,from(o remetente: um número de telefone em E.164, ou um nome de texto como “Google”, no máximo 32 caracteres;unknownse o remetente faltar),to(o seu número em que a mensagem chegou),body,segmentsereceivedAt(o momento em que a mensagem foi recebida, um timestamp em ms). É entregue através da fila de novas tentativas, tal como os eventos de chamadas. Num número de telemóvel, uma mensagem que não foi captada de imediato é acrescentada por uma verificação periódica em cerca de 15 minutos, e o evento é então enviado com atraso, possivelmente depois de mensagens mais recentes. Um número para chamadas e SMS não tem tal verificação: o operador repete a entrega de uma mensagem durante no máximo uma hora, pelo que uma mensagem se pode perder se a receção do nosso lado estiver em baixo durante mais tempo, e também não a encontrará emGET /v1/messages?direction=in.tsno envelope é o momento em que o evento foi criado (também em ms, ao passo quet=no cabeçalho da assinatura está em segundos). Acima de 300 mensagens recebidas por hora num número (um número de telemóvel ou um número para chamadas e SMS) o webhook não é enviado para as mensagens seguintes; continuam guardadas e encontra-as emGET /v1/messages?direction=in, que contém no máximo as 1000 mensagens recebidas mais recentes por conta e as guarda durante 90 dias. Para um número para chamadas e SMS em libertação (estadoreleasing), uma mensagem é guardada masmessage.receivednão é enviado para ela. Uma conta que o operador da volai suspendeu manualmente não recebe qualquermessage.received, e para as mensagens recebidas durante a suspensão o evento não é enviado retroativamente quando a conta é restabelecida; encontra-as também emGET /v1/messages?direction=in.
Escolhe a quais subscrever com o campo events em PUT /v1/webhook. Um detalhe pouco intuitivo: um array vazio significa todos, e não nenhum - e isso vale também para eventos acrescentados no futuro. Para desativar mesmo a entrega, elimine o webhook no portal.
O corpo de cada evento de chamada (call.completed, call.failed, call.no_answer, call.missed) traz também kind, o tipo de chamada, o mesmo que em GET /v1/calls (agent, bridge, relay, inbound, transfer, sip, ring, voice_message). Numa mensagem de voz (POST /v1/voice-messages) o corpo contém um bloco voiceMessage com text, repeat e, depois de a chamada terminar, repeatPlayed (quantas vezes a mensagem foi ouvida) e completed (foi lida até ao fim); uma mensagem não atendida chega como call.no_answer. Num toque de portão o corpo contém também um bloco ring com requestedSecs, outcome e rangSecs; as outras chamadas não têm a chave ring. Ambos os campos são aditivos, pelo que as integrações mais antigas simplesmente os ignoram.
Cada evento chega como um POST com este envelope:
call.completed
{
"event": "call.completed",
"ts": 1756111640000,
"event_id": "evt_V1StGXR8_Z5jdHi6B-myT",
"data": {
"id": "c_8f2ac1d4e5b0",
"direction": "in",
"from": "+420777123456",
"to": "+420601234567",
"status": "completed",
"durationSecs": 47,
"priceHal": 236,
"endReason": "completed",
"transcript": [
{ "role": "agent", "message": "Bom dia, fala o café Nula, em que posso ajudar?" },
{ "role": "caller", "message": "Bom dia, gostaria de encomendar dois cafés com leite para levar." },
{ "role": "agent", "message": "Com certeza, dois cafés com leite para levar, estarão prontos dentro de quinze minutos." }
],
"summary": "O interlocutor encomendou dois cafés com leite para levar, levantamento dentro de 15 minutos.",
"data": {
"name": "Maria Silva",
"coffee_count": 2,
"urgency": "normal"
},
"kind": "inbound"
}
}O data.data aninhado (sim, duas vezes - o data exterior é o envelope que todos os eventos partilham) são os dados que o agente captou da chamada de acordo com os seus dataFields - as chaves escolhe-as você, ver Agente de voz. Um valor null significa que não foi mencionado na chamada. Se o agente não tiver nenhum campo configurado, a chave data nem sequer consta do corpo - tal como transcript e summary numa chamada sem agente.
A gravação não viaja no corpo do webhook
O áudio não é enviado com o webhook, nem o indicador hasRecording. Quando quiser a gravação, use GET /v1/calls/{id} (indica-lhe hasRecording) e descarregue-a de GET /v1/calls/{id}/recording - o formato segue o cabeçalho content-type da resposta: audio/mpeg (MP3) para um agente no ElevenLabs, atualmente audio/ogg para um agente no motor da volai. Guardamos as gravações durante 90 dias após a chamada.
call.failed
{
"event": "call.failed",
"ts": 1756111640000,
"event_id": "evt_Qp3xLk9F-2WyoTr8cNaZ1",
"data": {
"id": "c_9d4e2b7f1a63",
"direction": "out",
"from": "+420601234567",
"to": "+420777998877",
"reason": "rejected",
"endReason": "failed",
"kind": "agent"
}
}call.no_answer
call.missed (uma chamada RECEBIDA perdida) tem o mesmo formato - só diferem o nome do evento, direction e status.
{
"event": "call.no_answer",
"ts": 1756111640000,
"event_id": "evt_9mYbT_R2xVn7Kd4LpZaQ8",
"data": {
"id": "c_3b7e1c9a4f20",
"direction": "out",
"from": "+420601234567",
"to": "+420777998877",
"status": "no_answer",
"durationSecs": 0,
"priceHal": 0,
"answeredBy": "unknown",
"endReason": "no_answer",
"kind": "agent"
}
}Um toque de portão não atendido (POST /v1/rings) chega também como call.no_answer, com kind: "ring" e um bloco ring: outcome é rang (tocou até ao fim do tempo pedido) ou ended_early (a chamada terminou antes, por exemplo porque o portão a recusou, o que significa que abriu). Um toque atendido (outcome: "answered") chega como call.completed. Uma exceção: quando o operador confirma o atendimento apenas no seu registo de chamadas (CDR), depois de a volai já ter fechado o toque como não atendido, chega apenas call.no_answer, e o registo em GET /v1/calls/{id} passa mais tarde a completed com ring.outcome: "answered" e os segundos atendidos faturados; não é enviado um segundo evento. Um resultado unknown não desencadeia nenhum evento até os registos do operador o preencherem.
{
"event": "call.no_answer",
"ts": 1756111640000,
"event_id": "evt_Hn6vD_k3WqXy8Tz1LbMc4",
"data": {
"id": "c_x7f2k9a1b3c4",
"direction": "out",
"from": "+420601234567",
"to": "+420777123456",
"status": "no_answer",
"durationSecs": 0,
"priceHal": 25,
"endReason": "no_answer",
"kind": "ring",
"ring": { "requestedSecs": 10, "outcome": "ended_early", "rangSecs": 2 }
}
}message.sent
{
"event": "message.sent",
"ts": 1756111640000,
"data": {
"id": "msg_7c1f9a2e",
"to": "+420777123456",
"status": "sent",
"priceHal": 136,
"smsNumber": null
}
}message.received
{
"event": "message.received",
"ts": 1756111900000,
"event_id": "evt_4kTz_P8wQmL2Xc9RvNdA1",
"data": {
"id": "msg_3d9b5e71",
"from": "+420777123456",
"to": "+420770112233",
"body": "Obrigado, venho na sexta-feira às 10:00.",
"segments": 1,
"receivedAt": 1756111900000
}
}body é texto de um remetente externo. Trate-o como entrada não fiável: não execute comandos nem ações a partir dele sem verificar. O corpo traz um event_id que pode usar para filtrar uma entrega duplicada.
Verificação da assinatura
Cada pedido traz um cabeçalho Volai-Signature com a forma t=<unix>,v1=<hex>, onde v1 = HMAC_SHA256(secret, "{t}.{rawBody}") e secret é o que recebeu de PUT /v1/webhook. Nunca processe o corpo antes de verificar a assinatura - caso contrário qualquer pessoa pode enviar o que quiser para o seu URL.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyVolaiSignature(
rawBody: string,
signatureHeader: string | null,
secret: string,
toleranceSecs = 300,
): boolean {
if (!signatureHeader) return false;
const parts = new Map<string, string>();
for (const piece of signatureHeader.split(",")) {
const idx = piece.indexOf("=");
if (idx > 0) parts.set(piece.slice(0, idx), piece.slice(idx + 1));
}
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1) return false;
const age = Math.abs(Date.now() / 1000 - Number(t));
if (!Number.isFinite(age) || age > toleranceSecs) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
// Route handler do Next.js - rawBody TEM de ser exatamente o que veio pela rede (request.text()), e não JSON.parse e de volta para string.
export async function POST(request: Request) {
const rawBody = await request.text();
const valid = verifyVolaiSignature(
rawBody,
request.headers.get("volai-signature"),
process.env.VOLAI_WEBHOOK_SECRET!,
);
if (!valid) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(rawBody);
// ... processamento em segundo plano, ver Dicas abaixo
void event;
return new Response("ok", { status: 200 });
}import hashlib
import hmac
import time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_..."
def verify_volai_signature(raw_body, signature_header, secret, tolerance_secs=300):
if not signature_header:
return False
parts = {}
for piece in signature_header.split(","):
if "=" in piece:
key, value = piece.split("=", 1)
parts[key] = value
t = parts.get("t")
v1 = parts.get("v1")
if not t or not v1:
return False
if abs(time.time() - float(t)) > tolerance_secs:
return False
payload = f"{t}.{raw_body}".encode("utf-8")
expected = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.route("/webhooks/volai", methods=["POST"])
def volai_webhook():
raw_body = request.get_data(as_text=True)
signature = request.headers.get("Volai-Signature", "")
if not verify_volai_signature(raw_body, signature, WEBHOOK_SECRET):
return "invalid signature", 401
event = request.get_json()
# ... processamento em segundo plano, ver Dicas abaixo
del event
return "ok", 200Cabeçalhos personalizados
Quando o destino só aceita pedidos autenticados (por exemplo uma automação do Cursor ou a sua própria API atrás de um gateway), acrescente a chave como cabeçalho personalizado. É enviado com cada entrega e com o evento de teste, por exemplo Authorization: Bearer <key>. Defina-o com o campo headers de PUT /v1/webhook, com a ferramenta MCP set_webhook ou no portal, em Ligações e API, na secção Webhooks.
curl -X PUT https://volai.cz/v1/webhook \
-H "Authorization: Bearer $VOLAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.example.com/hooks/volai", "events": ["message.received", "call.completed", "call.missed"], "headers": {"Authorization": "Bearer YOUR_TARGET_KEY"}}'- No máximo 5 cabeçalhos, cada valor com 1 a 1000 caracteres ASCII imprimíveis (sem letras acentuadas). Os nomes não distinguem maiúsculas de minúsculas e têm de ser únicos.
host,content-type,content-length,volai-signaturee os cabeçalhos hop-by-hop são definidos pela própria entrega e não podem ser definidos. - Os valores são segredos:
GET /v1/webhookdevolve-os abreviados (Bear***). Se enviar de volta uma tal máscara, o valor guardado é mantido. - Omitir
headersmantém os guardados,{}remove-os. Quando altera o URL para um endereço diferente (outro domínio, esquema ou porta, mesmo umwww.acrescentado), os cabeçalhos guardados são descartados e introduz novamente os valores completos: não enviamos uma chave para um endereço diferente. Se nesse momento enviar uma máscara em vez de um valor, recebe o erroinvalid_webhook_headers(400), tal como para um cabeçalho proibido ou inválido. - Quando uma entrega é redirecionada para um endereço diferente, os cabeçalhos personalizados não são enviados; o cabeçalho
Volai-Signaturecontinua a ser. Um redirecionamento deexample.comparawww.example.comtambém é um endereço diferente, por isso indique no webhook o URL final.
A assinatura Volai-Signature continua a ser enviada juntamente com os cabeçalhos personalizados. O cabeçalho deixa o pedido entrar, a assinatura prova que foi mesmo a volai a enviá-lo.
Novas tentativas em caso de falha
Se o seu endpoint não responder com um estado de sucesso (2xx), a volai tenta mais duas vezes - 3 tentativas no total, com uma pausa de 3 segundos entre elas. Os eventos de chamadas (`call.completed`, `call.failed`, `call.no_answer`, `call.missed`) e `message.received` não ficam por aqui: se as três tentativas falharem, o evento permanece em fila e a entrega é repetida mais algumas vezes nos minutos seguintes, até 5 rondas no total. É por isso que o corpo traz um `event_id` - use-o para filtrar um duplicado, no raro caso de o mesmo evento chegar mais de uma vez; uma entrega repetida traz exatamente o mesmo corpo que a primeira (só enviada alguns minutos depois). `message.sent` não tem fila: após 3 tentativas falhadas é registado e não volta a ser enviado.
O evento de teste abaixo é a exceção - é enviado só uma vez, sem nova tentativa, para que obtenha uma resposta de imediato.
O webhook não é a única fonte de verdade
Um webhook traz apenas um resumo - para tudo o que realmente importa (faturação, auditorias, o texto exato de uma mensagem), obtenha o registo completo da API REST: GET /v1/calls/{id} e GET /v1/messages/{id}, sempre que precisar.
Evento de teste
Antes de esperar pela primeira chamada real, verifique a entrega logo - POST /v1/webhook/test (ver a API REST) envia um evento de exemplo webhook.test para o seu URL, INDEPENDENTEMENTE dos events que tenha assinalado em PUT /v1/webhook - testa a capacidade de entrega do URL e da própria assinatura, não a subscrição de eventos concretos. No máximo 10 tentativas por hora. O teste envia apenas webhook.test, não os outros eventos: só pode experimentar o formato de message.received com um SMS real para o seu número de telemóvel ou número para chamadas e SMS (ver o exemplo acima).
Histórico de entregas
Pode ver as últimas 50 entregas (bem-sucedidas, falhadas e de teste) através de GET /v1/webhook/deliveries (ver a API REST), ou diretamente no portal, abaixo do formulário do webhook. O mesmo resumo está também incluído em GET /v1/webhook como recentDeliveries.
Dicas
- Responda com 200 o mais depressa possível - verifique a assinatura e guarde o ID de imediato, e passe o trabalho pesado (e-mails, recálculos, chamadas a outras APIs) para segundo plano.
- O endpoint tem de ser acessível publicamente por HTTPS -
localhoste certificados autoassinados não funcionam. - Use
data.id(o ID da chamada ou da mensagem) para filtrar duplicados, caso o mesmo evento chegue mais de uma vez. - Teste localmente através de um túnel (ngrok, por exemplo) e aponte temporariamente o URL em
PUT /v1/webhookpara o seu endereço.
Relacionado
Servidor MCP
Ligue o Claude Code, o Codex CLI, o Codex desktop, o Cursor e outros editores de IA.
API REST
Referência completa de todos os endpoints: números, chamadas, SMS, números de telemóvel para SMS, agentes e os seus rascunhos, ferramentas, gravações, webhooks, lista de não contactar, relay, conta, tarefas, saldo, documentos, calendários e integrações.
Agente de voz
Prompt de sistema, ferramentas, passagem a uma pessoa, gravações e dados estruturados da chamada.