Saltar para o conteúdo

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). durationSecs está 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, com endReason: "caller_loop" (preço 0 se 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 de call.no_answer. Uma chamada no motor próprio da volai (versão de 15 out. 2026 em diante) traz ainda um bloco engine com protectedUtterances e protectedUtterancesCount: 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 em transcript, o agente não reagiu a elas; mesmo formato que em GET /v1/calls/{id}, ausente no ElevenLabs e em versões mais antigas do motor.
  • call.failed - a chamada acabou em status: "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 traz reason (a causa técnica) e endReason. 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ém transcript/summary/durationSecs/priceHal - qualquer outro reason omite estes quatro campos. Para uma tal chamada, reason é engine_stall ou engine_error (uma falha do motor), ou unfinished quando 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, e summary explica em poucas palavras o que aconteceu. O transcript conté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 bloco engine com 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 por kind: "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 tem endReason: "capacity", duração 0 e preço 0; 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 campo smsNumber traz o número de que foi enviado e é null para 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 em GET /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. data traz id, from (o remetente: um número de telefone em E.164, ou um nome de texto como “Google”, no máximo 32 caracteres; unknown se o remetente faltar), to (o seu número em que a mensagem chegou), body, segments e receivedAt (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á em GET /v1/messages?direction=in. ts no envelope é o momento em que o evento foi criado (também em ms, ao passo que t= 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 em GET /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 (estado releasing), uma mensagem é guardada mas message.received não é enviado para ela. Uma conta que o operador da volai suspendeu manualmente não recebe qualquer message.received, e para as mensagens recebidas durante a suspensão o evento não é enviado retroativamente quando a conta é restabelecida; encontra-as também em GET /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

json
{
  "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

json
{
  "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.

json
{
  "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.

json
{
  "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

json
{
  "event": "message.sent",
  "ts": 1756111640000,
  "data": {
    "id": "msg_7c1f9a2e",
    "to": "+420777123456",
    "status": "sent",
    "priceHal": 136,
    "smsNumber": null
  }
}

message.received

json
{
  "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.

typescript
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 });
}
python
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", 200

Cabeç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.

bash
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-signature e 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/webhook devolve-os abreviados (Bear***). Se enviar de volta uma tal máscara, o valor guardado é mantido.
  • Omitir headers mantém os guardados, {} remove-os. Quando altera o URL para um endereço diferente (outro domínio, esquema ou porta, mesmo um www. 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 erro invalid_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-Signature continua a ser. Um redirecionamento de example.com para www.example.com també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 - localhost e 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/webhook para o seu endereço.