Put a voice agent in your website or app
Answer and place phone calls, let visitors talk to your agent from the browser, and receive what callers ask for — bookings, orders, appointments — straight into your own system.
Overview
How it fits together:
- Your Voxa account holds your voice agent: its greeting, voice, language and phone number. Add your business details — services, prices, opening hours, FAQs — to its Knowledge Base in Voxa; the agent answers callers from it.
- Sutrixa gives you an API key (
vxa_live_…) for that account. It stays on your server, in your.envfile. - Your website calls the API to have the agent ring customers, to start calls from the browser and to read calls. All the integration code lives on your side; nothing changes in Voxa.
- During any call — incoming, outgoing or from your website — when a caller asks for something, the agent collects the details, reads them back, and once they confirm, records a request. Voxa sends it to your webhook at once, so your system can create the booking or order.
- Prefer no server code? The website widget adds a "Talk to us" button with one
<script>line.
Quick start
1. Ask Sutrixa for an API key (and, to receive requests, give them your webhook address). Add the key to your server's .env:
VOXA_API_URL=https://voxa.sutrixa.com/api/v1 VOXA_API_KEY=vxa_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx VOXA_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
2. Check it works by listing your agents:
curl https://voxa.sutrixa.com/api/v1/agents \ -H "Authorization: Bearer $VOXA_API_KEY"
3. Have your agent ring a customer:
curl -X POST https://voxa.sutrixa.com/api/v1/calls/outbound \
-H "Authorization: Bearer $VOXA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"agent_id":"<agent id>","phone":"+919876543210","name":"Priya","about":"Confirm her table for 4 at 8 pm"}'Authentication & errors
Send the key with every request as Authorization: Bearer <key> (or X-Api-Key: <key>). Call the API from your server only: a key in a web page can be copied and used to place calls billed to you. Requests and answers are JSON.
Errors use HTTP status codes and always have this shape:
{ "error": { "code": "agent_not_live", "message": "This agent isn't live. Set it live in Voxa first." } }| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key · invalid_api_key · revoked_api_key | No key, a wrong key, or a revoked one |
| 400 | invalid_json · invalid_agent_id · invalid_phone | Something in the request is not right; the message says what |
| 402 | insufficient_balance | The account's Voxa wallet can't pay for a call |
| 404 | agent_not_found · call_not_found · request_not_found | No such thing in your account |
| 405 | method_not_allowed | Wrong HTTP method for the endpoint |
| 409 | agent_not_live · call_refused | The agent isn't live, or the call can't be placed (the message says why) |
| 429 | too_many_calls | Widget only: too many calls started, try again shortly |
| 503 | engine_unavailable | Calls are temporarily unavailable |
Agents
Your agents and their phone numbers. Customers can ring a live agent on its phone_number — for example from a "Call us" link on your site — and the agent answers.
{
"agents": [
{
"id": "8c2f…",
"name": "Front desk",
"language": "en",
"voice": "aria",
"phone_number": "+919429392800",
"status": "active",
"live": true,
"created_at": "2026-10-20T10:00:00Z"
}
]
}Agent calls a customer
The agent rings the number within a few seconds, at any hour, greets the customer by name and says why it is calling. One try; billed per minute like any phone call.
| Field | Required | Description |
|---|---|---|
agent_id | yes | The agent that calls (GET /agents) |
phone | yes | The customer's number with country code, e.g. +919876543210 |
name | no | The customer's name, so the agent can greet them |
about | no | What the call is about, up to 500 characters — the agent uses it to open the call |
HTTP/1.1 202 Accepted
{ "id": "5b1d…", "status": "queued", "phone": "+919876543210" }Where the call has got to: queued → calling → in_progress → completed, or failed / cancelled. When it failed, message says why in plain words ("Nobody picked up.", "Their line was busy."). Once completed, call_id gives the call with its transcript.
{ "id": "5b1d…", "agent_id": "8c2f…", "phone": "+919876543210",
"status": "completed", "message": null, "call_id": "e41a…", "created_at": "…" }Customer calls from your website
Lets a visitor talk to your agent with their microphone, no phone needed. Ask for a call from your server, then open the join_url it returns for the visitor — in a popup, a new tab, or an iframe with allow="microphone". It must be opened within 5 minutes and works for one call.
| Field | Required | Description |
|---|---|---|
agent_id | yes | The agent the visitor talks to |
customer.name | no | The visitor's name; the agent uses it |
customer.phone | no | Their number; lets the agent book a call back and fills requests |
context | no | What your site knows, up to 1000 characters, e.g. "Guest in room 204, checking out tomorrow" |
// Your server (Node / Express), e.g. POST /voice-call
app.post("/voice-call", async (req, res) => {
const r = await fetch(`${process.env.VOXA_API_URL}/calls/web`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VOXA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
agent_id: process.env.VOXA_AGENT_ID,
customer: { name: req.user?.name, phone: req.user?.phone },
context: "Logged-in customer on the booking page",
}),
});
const call = await r.json(); // { call_id, join_url, expires_in, … }
res.json({ join_url: call.join_url });
});<!-- Your page -->
<button onclick="talk()">Talk to us</button>
<script>
async function talk() {
const { join_url } = await fetch("/voice-call", { method: "POST" }).then((r) => r.json());
window.open(join_url, "voxa-call", "width=420,height=640");
}
</script>The answer also has livekit_url and token for apps that build their own call screen with livekit-client. Its call_id is the call's call_ref: use it with GET /api/v1/calls/{call_ref} after the call.
Calls & transcripts
Every call — phone, website and outbound — newest first. limit is 1–100. For the next page pass the next_before you got as before. phone matches by digits.
{
"calls": [
{
"id": "e41a…",
"call_ref": "f7c0…",
"agent_id": "8c2f…",
"channel": "phone", // phone | web | test
"direction": "inbound", // inbound | outbound
"customer_phone": "+919876543210",
"agent_phone": "+919429392800",
"duration_seconds": 95,
"outcome": "completed",
"cost_cents": 19,
"started_at": "…",
"ended_at": "…",
"summary": null
}
],
"next_before": null
}One call, with its transcript and the requests made during it.
Requests (bookings, orders…)
When a caller asks your business for something — a room, a table, food, an appointment, a quote — the agent collects what is needed, reads it back and, once they confirm, records a request. It tells the caller the team will confirm, and never promises availability itself: your system decides (for example creates the order, or a booking for your staff to confirm). Requests reach you as request.created webhooks the moment they are made, and can be read any time:
{
"id": "a93e…",
"type": "order", // booking | reservation | order | appointment | quote | enquiry | …
"summary": "2 Paneer Tikka (half) and 1 Butter Naan to room 204",
"customer": { "name": "Priya Sharma", "phone": "+919876543210" },
"date": null, // YYYY-MM-DD, when it is for a day
"time": null, // HH:MM, 24-hour
"items": [
{ "name": "Paneer Tikka", "quantity": 2, "notes": "half portion" },
{ "name": "Butter Naan", "quantity": 1, "notes": null }
],
"notes": "Less spicy",
"agent_id": "8c2f…",
"call_ref": "f7c0…",
"channel": "phone",
"direction": "inbound",
"created_at": "2026-10-26T14:05:12Z"
}type is a short lower-case word chosen by the agent; match the ones you handle and treat others as messages for your team. customer.phone is the number they gave, or the one they called from. Dates and times are in the account's time zone.
Webhooks
Give Sutrixa an https address on your server. Voxa sends it a POST with a JSON body for each event:
| Event | When | data |
|---|---|---|
request.created | A caller confirmed a request | The request (see above) |
call.ended | A call finished | The call with transcript and requests |
ping | A test sent from the Voxa admin panel | A message |
{
"id": "d1f6…", // unique per event: ignore repeats
"type": "request.created",
"created_at": "2026-10-26T14:05:12Z",
"livemode": true, // false for browser test calls
"data": { …the request… }
}Verify every event. The X-Voxa-Signature header is t=<unix seconds>,v1=<hex>, where hex is HMAC-SHA256 of "<t>.<raw body>" with your webhook secret. Compare it with the raw body, before parsing, and refuse old timestamps:
// Node / Express
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/api/voxa-webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Voxa-Signature") ?? "";
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.VOXA_WEBHOOK_SECRET)
.update(`${t}.${req.body}`)
.digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
const same = !!v1 && v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!fresh || !same) {
return res.status(400).send("Bad signature");
}
const event = JSON.parse(req.body);
if (event.type === "request.created") {
const r = event.data;
// e.g. create the order or booking in your system
// if (r.type === "order") createOrder(r) ; if (r.type === "booking") createBookingRequest(r)
}
res.sendStatus(200); // answer 2xx within 10 seconds
});// Next.js (app router): app/api/voxa-webhook/route.ts
import crypto from "node:crypto";
export async function POST(req: Request) {
const raw = await req.text();
const { t, v1 } = Object.fromEntries(
(req.headers.get("x-voxa-signature") ?? "").split(",").map((p) => p.split("=")),
);
const expected = crypto
.createHmac("sha256", process.env.VOXA_WEBHOOK_SECRET!)
.update(`${t}.${raw}`)
.digest("hex");
if (!v1 || v1.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return new Response("Bad signature", { status: 400 });
}
const event = JSON.parse(raw);
// …handle event.type / event.data…
return new Response("ok");
}- Answer with a 2xx within 10 seconds; do slow work afterwards.
- An event that fails is retried after 1 minute, 5 minutes, 30 minutes, 2, 6 and 12 hours. Use
id(also sent asX-Voxa-Delivery) to ignore one you already handled. - Headers sent:
X-Voxa-Event,X-Voxa-Delivery,X-Voxa-Signature. Redirects are not followed.
Website widget
The quickest way to let visitors talk to your agent: paste one line before </body>. A "Talk to us" button appears in the corner; pressing it opens the call in a small window. No server code needed.
<script src="https://voxa.sutrixa.com/widget.js" data-key="vxa_pub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" async></script>
The widget key (vxa_pub_…) is public — it is meant to be in your page. Sutrixa sets which website addresses it works on and which agent it calls, so it can't be used anywhere else. Ask them to list every address your site uses (with and without www).
| Attribute | Default | Description |
|---|---|---|
data-key | — | Your widget key (required) |
data-label | Talk to us | The button's text |
data-color | #0e7490 | The button's colour |
data-position | right | right or left corner |
data-button | true | false hides the button; open the call from your own with VoxaWidget.open() |
<script src="https://voxa.sutrixa.com/widget.js" data-key="vxa_pub_…" data-button="false" async></script> <button onclick="VoxaWidget.open()">Speak to our assistant</button>
Requests made in widget calls reach your webhook like any other. To pass the visitor's name, number or other details to the agent, use website calls from your server instead.
Billing & limits
- Phone, outbound and website calls are billed per minute from the account's Voxa wallet. Browser test calls made inside Voxa are free and marked
livemode: false/channel: "test". - A call is refused (402) when the wallet can't cover at least a few seconds, and ends when it runs out.
- Calls last at most 10 minutes.
- Widget: at most 5 calls a minute per visitor and 120 an hour per widget key.
- Questions or a new key: connect@sutrixa.com.