BE VOICEby Binary Elements

For developers

Receive webhooks from BE Voice

BE Voice can tell your system when things happen: a call starts or ends, a call you placed has its result, the AI agent takes a message. Each is a signed JSON POST to an address you choose.

Ways to receive events

Where you set itWhat arrives
The call events address: Settings → Integrations → Call eventsCall events (call.*), queue events (queue.*) and AI agent events (ai.*) for every call on your account, incoming and outgoing.
callback_url on a call you placecall.result: that call's outcome, answers, summary and transcript, once, when it's final.
The message webhook: Settings → Messages and alerts → To a webhookmessage.created: every new voicemail and every message the AI agent takes.

To have your own software decide where an incoming call goes, rather than just hear about it, use the Webhook step in call routing. Campaigns that take their list from your system send each person's result back to it as well. See Integrations for agents: campaigns from your system.

How to get a call's result: callback_url

Add callback_url when you place a call. It must be https on the public internet. When the call has ended and its summary is written, BE Voice posts:

POST /hooks/be-voice HTTP/1.1
Host: app.example.com
Content-Type: application/json
User-Agent: be-voice-webhooks/1
X-BE-Voice-Timestamp: 1791609205
X-BE-Voice-Signature: sha256=5f0c3b8e1d…

{
  "id": "evt_3m8q2x7c1v9k4b6n0z5d",
  "type": "call.result",
  "created_at": "2026-10-10T05:13:29.017Z",
  "tenant_id": "tnt_8f2kd0w3m5q7r9t1v4xz",
  "data": {
    "call_id": "cal_7h2kq9m4x8pz3vd61wtn",
    "status": "completed",
    "final": true,
    "from": "+61370000000",
    "to": "+61400000000",
    "duration_sec": 74,
    "outcome": "completed",
    "result": {"outcome": "completed", "answers": {"window": "Friday 12-5"}, "note": "Ring on arrival."},
    "summary": "Sam confirmed delivery on Friday between 12 and 5.",
    "transcript": [{"speaker": "agent", "text": "…"}, {"speaker": "caller", "text": "…"}],
    "metadata": {"order": "A1002"}
  }
}

data is exactly what GET /v1/calls/{id}/result returns; Follow, steer and get results explains each field. It's also sent when nobody answers, with outcome no-answer, busy, failed or canceled. tenant_id is your account ID.

How to get every call's events: the call events address

  1. Open Settings → Integrations.
  2. Under Call events, enter your receiver's address (https, on the public internet).
  3. Click Save. To stop the events, clear the address and save.
The Call events panel with the address https://app.example.com/hooks/be-voice and a Save button
One address receives the events of every call on your account.

You can also set it through the API: PATCH /v1/tenants/{your account ID}/settings with {"events_url": "https://app.example.com/hooks/be-voice"}, or "" to clear it.

The events

EventWhen
call.initiatedA call arrives on one of your numbers, or a call you placed starts ringing.
call.answeredA call is answered.
call.updatedThe call reached a new kind of step. data.handler says which: ai (an AI agent, with data.ai.agent_id), menu or voicemail.
call.endedThe call is over: status, duration_sec and hangup_cause are final.
queue.entered, queue.exited, queue.timeoutA caller joined a queue (with their position), left it (reason answered, with who answered, or hangup), or waited too long and moved on (max_wait or no_agents). The details are in data.queue, with wait_sec.
ai.transferThe AI agent is putting the caller through to someone (data.ai.transfer: name, target and how it went).
ai.messageThe AI agent took a message: data.ai.message with summary and the details it collected.
ai.summaryThe AI conversation is over: summary, transcript, how it ended (outcome), and the agent's result if it ended with one.

Every event's data is the call (the same fields as GET /v1/calls/{id}) plus the extra object for that event. A call's events arrive in the order they happened.

{
  "id": "evt_9w2d5k8r1m4q7t0x3c6v",
  "type": "ai.message",
  "created_at": "2026-10-10T01:19:40.552Z",
  "tenant_id": "tnt_8f2kd0w3m5q7r9t1v4xz",
  "data": {
    "id": "cal_2n5r8u1x4a7d0g3j6m9p",
    "tenant_id": "tnt_8f2kd0w3m5q7r9t1v4xz",
    "direction": "inbound",
    "from": "+61400000000",
    "to": "+61370000000",
    "status": "in-progress",
    "duration_sec": 0,
    "created_at": "2026-10-10T01:18:02.114Z",
    "answered_at": "2026-10-10T01:18:03.020Z",
    "ended_at": null,
    "ai": {
      "agent_id": "agt_4k8p2w6r0t3y7u1i5o9e",
      "message": {"summary": "Jo has a blocked drain at the back of the house.", "details": {"name": "Jo", "suburb": "Port Melbourne"}}
    }
  }
}

How to get every message: the message webhook

  1. Open Settings → Messages and alerts.
  2. Under To a webhook, enter your receiver's address.
  3. Click Send a test. Your receiver gets a sample message.test event, and the console tells you whether it answered.
  4. Click Save.

Each new voicemail and AI message then arrives as message.created, after the call ends. It works with or without email, and missed calls aren't sent. An AI agent can have its own webhook for the messages it takes, set in its editor under Messages; see Messages and alerts.

{
  "id": "evt_6t1y4u7i0o3p5a8s2d9f",
  "type": "message.created",
  "created_at": "2026-10-10T01:20:00.000Z",
  "tenant_id": "tnt_8f2kd0w3m5q7r9t1v4xz",
  "data": {
    "message": {
      "id": "msg_4h7j0k3l6z9x2c5v8b1n",
      "kind": "ai_message",
      "caller": "+61400000000",
      "summary": "Jo has a blocked drain at the back of the house.",
      "details": {"name": "Jo", "suburb": "Port Melbourne"},
      "agent_id": "agt_4k8p2w6r0t3y7u1i5o9e",
      "status": "new",
      "created_at": "2026-10-10T01:19:40.000Z"
    },
    "call": {"id": "cal_2n5r8u1x4a7d0g3j6m9p", "from": "+61400000000", "to": "+61370000000", "created_at": "2026-10-10T01:18:02.114Z"}
  }
}

A voicemail has "kind": "voicemail", its length in seconds, the mailbox's mailbox_id and mailbox_name, and a recording_url. Fetch the recording with one of your API keys in the Authorization header.

How to check a webhook came from BE Voice

Every webhook is signed with your account's webhook secret (it starts whsec_). Find it in Settings → Integrations → Webhook secret: click Show or Copy. Keep it secret, like an API key.

The Webhook secret panel with the secret hidden and Show, Copy and Replace buttons
The secret stays hidden until you click Show.

Replace makes a new secret and the old one stops working at once: every webhook and every tool call to your systems is signed with the new one from the next event. Update your systems straight after, or they'll reject what BE Voice sends. Only people signed in to the console can see or replace the secret; API keys can't, so a leaked API key can't be used to fake our webhooks.

Two headers come with each request:

X-BE-Voice-Timestamp
When it was sent, in Unix seconds.
X-BE-Voice-Signature
sha256= followed by the hex HMAC-SHA256 of the timestamp, a full stop, and the raw request body (for example 1791609205.{"id":"evt_…",…}), keyed with your webhook secret.
  1. Read the body as raw bytes, before any JSON parsing. Parsing and re-encoding changes the bytes and breaks the signature.
  2. Reject a timestamp more than 5 minutes from your clock. That stops an old request being replayed.
  3. Work out the signature yourself and compare it to the header with a constant-time comparison.
  4. Only then parse the JSON and act on it.

In Node.js with Express:

import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.BE_VOICE_WEBHOOK_SECRET; // whsec_…
const app = express();

app.post("/hooks/be-voice", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-BE-Voice-Timestamp") || "";
  const sig = req.get("X-BE-Voice-Signature") || "";
  const want = "sha256=" + crypto.createHmac("sha256", SECRET)
    .update(ts + ".").update(req.body).digest("hex");
  const fresh = !(Math.abs(Date.now() / 1000 - Number(ts)) > 300);
  const ok = fresh && sig.length === want.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body);
  res.sendStatus(204);   // answer first...
  handle(event);         // ...then do the work
});

In Python:

import hashlib, hmac, time

def verify(secret: str, ts: str, sig: str, body: bytes) -> bool:
    if abs(time.time() - int(ts)) > 300:
        return False
    mac = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + mac, sig)

Responding, retries and duplicates

  • Answer quickly with any 2xx. Anything else, or no answer within a few seconds, counts as a failure. Do slow work after you've replied. The body of your reply is ignored.
  • Call events and call.result are sent once. If your receiver is down, they aren't sent again. For calls you placed, GET /v1/calls/{id}/result always has the result, so check it for any call you didn't hear about.
  • message.created is tried up to three times, a few seconds apart. The message is also always on the console's Messages tab.
  • Handle each event once. Every event has its own id (evt_…), the same on each try. Store the IDs you've handled and skip repeats. For call.result, the call_id works just as well.

Still stuck? Contact us and tell us which page you were on.