Integrations for agents
Integrations: agents calling your API
An integration lets your AI agent call your API while it's on a phone call: look up a job, check a balance, record a note. This guide says what your API should look like so that works well: the agent finds the right record, understands the reply, says the right thing, and writes safely.
Any JSON API can be connected, with no adapter. Each rule below is marked Must (without it the request fails or is unsafe) or Should (it works, but calls are slower, vaguer or need more setting up). An API that follows them is what we call agent-ready.
How it works
The business connects your API to its AI agent once, in the console, with no code:
- A connection: your API's base address and one sign-in header.
- Tools: one per endpoint the agent may use. Each has a method and path, the values the agent must collect from the caller (a job number, a date), and a line saying when to use it.
- What the agent gets: the fields of your reply worth saying, and the business's notes on how to read them ("ON_THE_WAY means the plumber is driving there: give the arrival window").
On a call, the agent collects the values, BE Voice sends the request, and the agent words its answer from your reply. The business can instead give a fixed sentence filled from your reply, for wording that must be exact.
Caller: "Is the plumber still coming today? The job number is HJ-1042."
Agent → GET https://api.example.com/jobs/HJ-1042
Your API → {"job": {"number": "HJ-1042", "status": "ON_THE_WAY", "window": "1 pm to 3 pm", "plumber": "Sam"}}
Agent: "Yes, Sam's on the way now and should be with you between 1 and 3 this afternoon."
Setting this up in the console is covered in Connect your API to an agent.
Two ways a tool can call you
- Your existing API (most integrations)
- The tool calls your endpoint as it is, with
GET,POST,PUT,PATCHorDELETE. Values go in the path, the query string or a JSON body, as your API expects. In the console this is Existing API · GET (read) and so on. - A BE Voice request
- The tool sends one
POSTin BE Voice's own format, with the values and details of the call. Use it when you build an endpoint just for the agent. In the console this is Your system · BE Voice request (POST).
POST /voice/tools
Content-Type: application/json
{
"event": "ai.tool",
"tool": "look_up_job",
"arguments": {"job_number": "HJ-1042"},
"call": {"id": "cal_8f2k…", "direction": "inbound", "from": "+61400000000", "to": "+61370000000"},
"context": {"customer_name": "Jordan"}
}
call.metadata is added when the call carries metadata, and context when the call was given facts (a campaign contact's details, or the context sent with a call your system placed). A test from the console sends "event": "ai.tool.test" with no call. A BE Voice request's path has no {name} placeholders. Everything else on this page applies to both kinds.
Address and sign-in
| Rule | Level |
|---|---|
| HTTPS on a public address. Private, internal and loopback addresses are refused, including names that resolve to them. | Must |
| One base address per system; every tool is a path under it. | Must |
Sign-in is one fixed header: an API key or a long-lived token (Authorization: Bearer … or X-Api-Key: …). It is stored encrypted and sent on every request. Tokens that must be refreshed (OAuth flows) aren't supported. | Must |
| No redirects: answer on the address called. A redirect is treated as a failure, not followed. | Must |
| Give BE Voice its own key, limited to what the agent needs, so it can be revoked on its own. | Should |
| Check the signature, at least on writes (below). | Should |
BE Voice has no fixed source addresses, so don't rely on an allow-list of IP addresses.
Checking a request came from BE Voice
Every request carries two headers:
X-BE-Voice-Timestamp- When it was sent, in Unix seconds.
X-BE-Voice-Signaturesha256=followed by the hex HMAC-SHA256 of the timestamp, a dot, and the raw body (1791612000.{"job_number": …}), keyed with your account's webhook secret. The body is empty for aGETorDELETE, so the signed text is just the timestamp and a dot.
Compute it over the raw bytes you received (before parsing the JSON), compare in constant time, and reject timestamps more than 5 minutes old. It is the same signature as BE Voice's webhooks. Your account's webhook secret is in Settings → Integrations → Webhook secret.
import hashlib, hmac, time
def from_be_voice(headers, raw_body: bytes, secret: str) -> bool:
ts = headers.get("X-BE-Voice-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
signed = ts.encode() + b"." + raw_body
want = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, headers.get("X-BE-Voice-Signature", ""))
Requests
What BE Voice sends to an existing API:
| Part | What it is |
|---|---|
| Method | GET, POST, PUT, PATCH or DELETE, as the tool is set up. |
| Path | The tool's path with each {name} filled from what the agent collected, escaped as one path segment: /jobs/{job_number} becomes /jobs/HJ-1042. A value can't add / or change the path. |
| Query string | GET and DELETE: every collected value not used in the path, as ?name=value. |
| Body | POST, PUT, PATCH: JSON. Either the business's body template with the values filled in ({"text": "{note}", "source": "phone"}), or, with no template, the collected values as one JSON object. |
| Headers | Accept: application/json, Content-Type: application/json (with a body), User-Agent: be-voice-tools/1, your sign-in header, the two signature headers, and the call headers below. |
In a body template, a string that is exactly "{name}" is replaced by the value; a placeholder inside a longer string is replaced by its text. A lookup on a call looks like this:
GET /jobs/HJ-1042 HTTP/1.1
Host: api.example.com
Accept: application/json
User-Agent: be-voice-tools/1
X-Api-Key: your-key-for-be-voice
X-BE-Voice-Timestamp: 1791612000
X-BE-Voice-Signature: sha256=5d0c…
X-BE-Voice-Call-Id: cal_8f2k…
X-BE-Voice-Tool: look_up_job
Idempotency-Key: cal_8f2k…:look_up_job:3b1c9a0e5d7f2a44
Which call is this?
| Header | Value |
|---|---|
X-BE-Voice-Call-Id | The phone call's ID (cal_…). Absent on a test from the console. |
X-BE-Voice-Tool | The tool's name, for example look_up_job. |
Idempotency-Key | The call ID, the tool name and a hash of the request, joined with colons: cal_8f2k…:look_up_job:3b1c9a0e5d7f2a44. The same when the same tool sends the same request again on the same call; different otherwise. A test gets a fresh key each time (test-…). |
Store the call ID with anything the agent creates, and your records can link back to the call's recording and transcript.
Rules for your API
| Rule | Level |
|---|---|
| Look records up by something a caller knows or the business already holds: a job, order or invoice number, a phone number, a name with a postcode. Not an internal database ID, unless your system gave it to BE Voice with the call. | Must |
| Ignore fields and headers you don't know. | Must |
Take values as people say them. They arrive as text the agent heard: accept hj 1042, HJ-1042 and hj1042; ignore case and spaces in identifiers. | Should |
Dates and amounts: accept ISO dates (2026-10-10) and plain amounts (500, 500.00). Tell the business which format you need; it goes in the tool's When to use line, so the agent sends it that way. Accept free text ("Friday") only if your system can read it. | Should |
| One request does the whole job. Don't need a lookup that returns an ID and then a second call for the details. | Should |
Replies
The agent reads your reply, together with the business's notes on what the values mean, and words the answer itself.
| Rule | Level |
|---|---|
| JSON, UTF-8, up to 64 KB. More is a failure. | Must |
Statuses from a fixed, documented list of codes or words (BOOKED, ON_THE_WAY, DONE). The business writes one line per value for the agent, so the list must be stable and complete. | Must |
| Nothing the caller mustn't hear. The business can drop fields before the agent sees them, but the safest secret is one the reply never had: no card numbers, passwords or other customers' details. | Must |
| Small. What reaches the agent is cut at 2,000 characters. Return one record, or a short list (10 items or fewer) with a way to narrow it. | Should |
| One record under a named key, with plain field names. | Should |
| Answer the next question in the same reply: the arrival window with the status, the balance with the amount, the payment link with the invoice. | Should |
Unknown is null, not "", 0 or "N/A". | Should |
Dates as ISO 8601 with an offset, or as plain words; not Unix timestamps. Money as a number in a known currency, or as text ("$1,240.50"). | Should |
| No HTML or markup in text fields. | Should |
{
"job": {
"number": "HJ-1042",
"status": "BOOKED",
"date": "2026-10-13",
"window": "8 am to 10 am",
"plumber": "Alex",
"parts_on_order": null
}
}
The business picks which fields reach the agent with paths such as job.status, items.0.sku or items[].name (that field of every item). Plain, stable field names make that easy.
Optional: word the answer yourself
If your system can say it better than a general assistant would, return a top-level "answer" sentence with the record under "data". The agent is given your sentence first, then the data.
{"answer": "Job HJ-1042 is booked for Tuesday 13 October, with Alex arriving between 8 and 10 am.",
"data": {"status": "BOOKED"}}
Status codes
| Your answer | What the agent is told |
|---|---|
200, 201, 202 with JSON | Your reply, shaped by the tool's settings. |
204, or any 2xx with no body | "Done." |
2xx with a body that isn't JSON | A failure. |
404 | "Nothing was found", with your body. Use it for "no such record", never 200 with an empty body. |
400, 409, 422 with a sentence | "The system refused that: your sentence". The agent fixes the value and tries again, or tells the caller. |
400, 409, 422 with no sentence | A failure. |
3xx, 401, 403 | A failure. For 401 and 403, the connection's key is wrong. |
429, 5xx, a timeout | A failure. |
On a failure the agent says it couldn't check that right now and offers to take a message or arrange a call back. Nothing is retried. The business's notes on how to read the reply go with "nothing found" and refusals too, so they can say what the agent should do next.
Errors the agent can act on
Send refusals as a sentence a person could act on. It is read from error (a string, or an object with message), or else message or detail, up to 300 characters. It may be said to the caller, so keep it plain and free of internals.
HTTP/1.1 409 Conflict
Content-Type: application/json
{"error": "That time has just been taken. The next free times are 2:30 pm and 4 pm.", "code": "slot_taken"}
With that, the agent can offer 2:30 or 4 pm straight away instead of saying something went wrong.
Timing and limits
| Rule | Level |
|---|---|
| Answer within the tool's wait: 10 seconds unless the business sets another, from 3 to 20. After that it is a failure. | Must |
| Aim for under 2 seconds. The caller is on the line while you work; the agent says "One moment, let me check" first. | Should |
- Per call
- At most 8 tool requests on one phone call, across all the agent's tools, and one attempt each. After that the agent offers a message or a call back instead.
- Per agent
- Up to 10 tools.
Writes
| Rule | Level |
|---|---|
Safe to receive twice. The agent may repeat a write if the caller repeats themselves; the same note, booking or promise must not double up. Check the Idempotency-Key. | Must |
Check the values yourself. They are what the agent heard; refuse what makes no sense with 422 and a sentence. | Must |
| Return what was created, with its ID and the values as stored. The agent reads them back to the caller. | Should |
Make the agent's writes undoable by staff, and mark them as coming from the phone agent (the body template can send "source": "phone"). | Should |
POST /jobs/HJ-1042/notes HTTP/1.1
Idempotency-Key: cal_8f2k…:add_job_note:9e4d1c07a2b85f31
Content-Type: application/json
{"text": "Side gate is locked: call 10 minutes before arriving.", "source": "phone"}
HTTP/1.1 201 Created
{"note": {"id": "nt_5521", "job": "HJ-1042", "text": "Side gate is locked: call 10 minutes before arriving."}}
Who is on the line
BE Voice can't prove that a caller is who they say. An API that returns personal or account details makes that decision itself.
| Rule | Level |
|---|---|
For details only the customer should hear, require a second value the caller gives (a postcode, a date of birth, the amount of the last invoice) with the lookup, and answer 404 unless both match. | Must, for personal details |
| On calls the business places (campaigns), the business chose who to ring and the agent confirms the person first. Still return only that contact's records. | Should |
Describing your API
The business sets up each tool from your documentation. For every endpoint the agent may use, give it:
| You document | It becomes |
|---|---|
| A one-line purpose, and the formats you expect | The tool's When to use line |
| The values the endpoint needs, named as in the path, query or body | What the tool Collects from the caller |
| Which fields are safe and useful to say | Fields to keep |
| Every status value, what it means, and what should happen next | The notes on how to read the reply |
| An example reply | A quick check with the tool's Test button |
Checklist
- HTTPS, public, one base address, one fixed sign-in header, no redirects
- Lookups by something a caller knows; loose input accepted
- Small JSON replies with one named record and a fixed list of statuses
404for not found; refusals as{"error": "a sentence"}with400,409or422- Answers in under 2 seconds, always within the tool's wait
- Writes check the
Idempotency-Keyand return what was created - Signatures checked, at least on writes
- Nothing in a reply that a caller mustn't hear; personal details need a second value
- Each endpoint documented with its purpose, status values, safe fields and an example
