A Python sample app that uses Vobiz XML to hold callers, retry an available agent on every cycle, and offer voicemail once the attempt budget is spent.
When more calls arrive than there are people to answer them, the call has to wait somewhere. This example implements that waiting room entirely in Vobiz XML: the caller hears a greeting, then a hold cycle that plays audio for a fixed number of seconds and then tries to reach an agent. If the agent picks up, the two legs are bridged. If nobody picks up, the caller drops back into hold and the loop repeats until the configured attempt budget runs out, at which point the caller is offered a voicemail instead.
The routing side is a small FastAPI service with two faces. Vobiz talks to the
webhook endpoints (/answer, /queue-hold, /queue-try-agent, /dial-complete,
the voicemail endpoints and /hangup) and receives XML in reply. Your own
software talks to a plain JSON API (/agents, /queue/status, /queue/metrics)
to register agents as they come online, take them offline again, read how many
callers are waiting, and pull queue statistics. Agents are dispatched round-robin
from that pool, with a configurable fallback number used when nobody has
registered yet.
State lives in memory in queue_store.py: the agent pool, the round-robin
pointer, per-call wait metrics, and a per-call attempt counter. That keeps the
example to two files you can read in one sitting, and it is the first thing to
replace when you take the pattern further — the QueueStore docstring sketches
the Redis and Postgres swap.
By the end of the setup below you will have a phone number that answers, holds a caller with audio you choose, rings a real handset up to a configurable number of times, connects the two parties when the handset answers, records a voicemail when it does not, and exposes wait-time and abandonment metrics over HTTP.
- A support-line queue for a small team, where agents mark themselves available
from a desktop or mobile app that calls
POST /agents. - An after-hours overflow path: hold briefly, try the on-call phone, then take a voicemail with a callback promise.
- A round-robin sales queue that spreads inbound enquiries evenly across representatives and reports calls handled per person.
- A dispatch line for field or clinic staff, where whoever is on shift registers their mobile number for the shift and deregisters at the end of it.
- A queue-metrics feed for a wallboard or a BI job that polls
/queue/metricsfor average wait time, connection count and abandonment rate. - A test harness for hold experience: swap
HOLD_MUSIC_URLandHOLD_WAIT_SECSand listen to how the wait actually sounds before rolling it out.
Vobiz requests your Answer URL when a call arrives and executes whatever XML you
return. Every hold cycle here is one webhook round trip, so the loop is driven by
<Redirect> rather than by anything long-running on your server.
POST /answer CallUUID recorded, attempts = 0
└── <Speak> "All agents are busy. Please hold."
└── <Redirect> → /queue-hold
│
├── attempts >= MAX_WAIT_CYCLES ?
│ └── yes → <Speak> apology → <Redirect> → /queue-voicemail
│ └── <Record> → /voicemail-done → <Hangup>
│ └── callbackUrl → /voicemail-file (MP3 URL)
│
└── no → <Speak> agent-availability line
<Play loop="1"> hold audio
<Wait length="HOLD_WAIT_SECS"/>
└── <Redirect> → /queue-try-agent (attempts += 1)
│
├── no agent and no fallback number
│ └── <Redirect> → /queue-hold (next cycle)
│
└── agent selected round-robin
<Dial timeout="15" callerId="FROM_NUMBER">
<Number>agent</Number>
</Dial>
├── action → /dial-complete
│ ├── DialStatus=completed → connected,
│ │ <Speak> goodbye → <Hangup>
│ └── otherwise → <Redirect> → /queue-hold
└── Dial fell through → <Redirect> → /queue-hold
Three details are worth reading twice:
- The counter is incremented in
/queue-try-agent, not in/queue-hold. The gate at the top of/queue-holdtherefore compares attempts already made againstMAX_WAIT_CYCLES. With the code default of2, a caller hears the greeting, one hold cycle, a first dial attempt, a second hold cycle, a second dial attempt, and is then offered voicemail. - Hold audio and hold time are separate elements.
<Play loop="1">plays the clip once;<Wait length="HOLD_WAIT_SECS"/>holds the leg for the full period. If the clip is shorter than the wait, the remainder is silence. - The dial result is the final
DialStatus. Vobiz reportscompleted,busy,failed,cancel,timeoutorno-answerto theactionURL./dial-completetreatscompletedas a successful bridge and sends everything else back into hold. See Dial status reporting.
| File | Responsibility |
|---|---|
server.py |
FastAPI app. Serves the Vobiz XML webhooks, the agent/queue JSON API and /health; reads configuration; opens the ngrok tunnel when PUBLIC_URL is unset. |
queue_store.py |
QueueStore — in-memory agent pool with round-robin dispatch (next_agent_number), per-call QueueMetric records, queue status and aggregate metrics. |
.env.example |
Template for the environment file. Copy to .env, which is git-ignored. |
requirements.txt |
Pinned dependencies: FastAPI, Uvicorn, python-multipart, python-dotenv, pyngrok, requests, pydantic. |
LICENSE |
MIT licence text. |
call_attempts — the per-call attempt counter — lives in server.py as a plain
dictionary keyed by CallUUID. It is seeded in /answer and cleared on a
successful connection or on /hangup.
- A Vobiz account with a voice-enabled phone number (DID).
- A Vobiz application whose
Answer URL points at
<public-url>/answerand whose Hangup URL points at<public-url>/hangup, assigned to that number. - Python 3.9 or later (3.11 recommended) and
pip. - A public HTTPS URL for your server. For local development the app opens an ngrok tunnel for you; an ngrok auth token is needed for most accounts.
- A second phone to act as the agent, and a phone to call in from.
-
Clone the repository.
git clone https://github.com/vobiz-ai/Vobiz-Call-Queue-XML-Python.git cd Vobiz-Call-Queue-XML-Python -
Create a virtual environment and install the dependencies.
python3 -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt
-
Create your environment file. It must sit next to
server.py; the app loads the.envbeside the script regardless of your working directory.cp .env.example .env
-
Fill in
.env. SetFROM_NUMBERto your Vobiz DID in E.164 format andAGENT_NUMBERto the handset you want rung when no agent has registered over the API. AddNGROK_AUTH_TOKENif you are tunnelling locally. Every variable is described under Configuration. -
Start the server.
python server.py
The banner prints the base URL, the Answer URL to paste into Vobiz, and the effective
MAX_WAIT_CYCLESandHOLD_WAIT_SECS. -
Point your Vobiz application at the server. In the Vobiz console, set the application's Answer URL to
<base-url>/answer(POST) and its Hangup URL to<base-url>/hangup(POST), then make sure your DID uses that application. The Hangup URL is what lets the queue record abandoned calls. -
Register an agent and call the number.
curl -X POST http://localhost:8000/agents \ -H 'Content-Type: application/json' \ -d '{"number": "+15550003333", "name": "Ada"}'
Every variable the code reads, with the default that applies when the variable is
absent or empty. .env.example ships explicit values for the queue settings; the
defaults below are what server.py falls back to.
| Variable | Required | Default | Description |
|---|---|---|---|
FROM_NUMBER |
Yes | (empty) | Your Vobiz DID in E.164 format. Used as the callerId on the agent leg, and as the last-resort dial target when AGENT_NUMBER is unset. |
AGENT_NUMBER |
No | FROM_NUMBER |
Fallback agent handset, dialled when the agent pool is empty. Registering agents through POST /agents takes precedence. |
MAX_WAIT_CYCLES |
No | 2 |
Dial attempts allowed before the caller is offered voicemail. .env.example ships 3. |
HOLD_WAIT_SECS |
No | 10 |
Seconds the caller is held per cycle by <Wait>. .env.example ships 20. |
HOLD_MUSIC_URL |
No | https://actions.google.com/sounds/v1/alarms/beep_short.ogg |
Publicly reachable MP3 or OGG played once per hold cycle. Left blank, the default short beep is used. |
HTTP_PORT |
No | 8000 |
Port Uvicorn binds on 0.0.0.0. |
PUBLIC_URL |
No | (empty) | Public HTTPS base URL of this server. When set, ngrok is skipped and this value is used in every callback URL. Any trailing slash is stripped. |
NGROK_AUTH_TOKEN |
No | (empty) | ngrok auth token, applied before opening the tunnel. Only used when PUBLIC_URL is empty. |
The hold clip is not bundled with this repository and is not uploaded to Vobiz.
<Play> is given a URL and Vobiz fetches it at playback time, so HOLD_MUSIC_URL
must be reachable from the public internet. The built-in default points at a
short beep hosted by Google, which is fine for a first test call and worth
replacing with your own audio.
source .venv/bin/activate
python server.pyYou should see the startup banner:
============================================================
06 — Call Queue / Hold Music
Answer URL : https://<tunnel>.ngrok-free.app/answer
Add agent : POST https://<tunnel>.ngrok-free.app/agents
Queue status : GET https://<tunnel>.ngrok-free.app/queue/status
Metrics : GET https://<tunnel>.ngrok-free.app/queue/metrics
Max retries : 2 | Hold secs: 10
============================================================
Exercise the JSON API:
# Register two agents — calls alternate between them
curl -X POST http://localhost:8000/agents -H 'Content-Type: application/json' \
-d '{"number": "+15550003333", "name": "Ada"}'
curl -X POST http://localhost:8000/agents -H 'Content-Type: application/json' \
-d '{"number": "+15550004444", "name": "Grace"}'
curl http://localhost:8000/agents
curl http://localhost:8000/queue/status
curl http://localhost:8000/queue/metrics
curl http://localhost:8000/health
# Take an agent offline
curl -X DELETE http://localhost:8000/agents/+15550003333Then call your Vobiz number. The application log traces the whole path:
Caller joined queue — CallUUID=...
Trying agent +15550003333 — attempt=1, CallUUID=...
Agent +15550003333 did not answer — status=no-answer
Trying agent +15550004444 — attempt=2, CallUUID=...
Call connected to agent +15550004444 — CallUUID=...
Call ended — CallUUID=...
After the call, GET /queue/metrics returns the aggregate view:
{
"total_calls": 1,
"connected": 1,
"abandoned": 0,
"abandonment_rate_pct": 0.0,
"avg_wait_secs": 34.0
}| Method | Path | Returns | Description |
|---|---|---|---|
| POST | /answer |
XML | Entry point. Records the caller in the queue, zeroes the attempt counter, greets, redirects to /queue-hold. |
| POST | /queue-hold |
XML | Cycle gate. Offers voicemail once attempts reach MAX_WAIT_CYCLES; otherwise announces availability, plays hold audio, waits, redirects to /queue-try-agent. |
| POST | /queue-try-agent |
XML | Increments the attempt counter, picks the next agent round-robin and dials it with a 15-second ring timeout. |
| POST | /dial-complete |
XML | Dial action URL. DialStatus=completed records the connection and hangs up; anything else returns the caller to hold. |
| POST | /queue-voicemail |
XML | Records a message: maxLength="60", silence timeout="5", finishOnKey="*", playBeep="true", fileFormat="mp3". |
| POST | /voicemail-done |
XML | Record action URL. Thanks the caller and hangs up. |
| POST | /voicemail-file |
OK |
Record callbackUrl. Logs the finished recording URL. |
| POST | /hangup |
OK |
Hangup URL. Marks a still-waiting caller as abandoned and clears the attempt counter. |
/answer and /hangup read form fields posted by Vobiz (CallUUID,
DialStatus, RecordingDuration, RecordUrl); call_uuid and agent are
carried between hops as query-string parameters on the callback URLs this app
generates.
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /agents |
{"number": "+15550003333", "name": "Ada"} |
Registers an agent as available. Returns 201 with the agent record. |
| DELETE | /agents/{number} |
— | Takes an agent offline. 404 if the number is not registered. |
| GET | /agents |
— | Lists registered agents with calls_handled and last_call_at. |
| GET | /queue/status |
— | callers_waiting, agents_available, and the agent list. |
| GET | /queue/metrics |
— | total_calls, connected, abandoned, abandonment_rate_pct, avg_wait_secs. |
| GET | /health |
— | Liveness check, echoing the base URL and current queue status. |
| Element | Used for | Attributes set here |
|---|---|---|
<Speak> |
Greeting, availability line, connection and sign-off prompts | voice="WOMAN", language="en-US" |
<Play> |
Hold audio from HOLD_MUSIC_URL |
loop="1" |
<Wait> |
The hold period itself | length="HOLD_WAIT_SECS" |
<Dial> with <Number> |
Ringing the selected agent | action, method="POST", timeout="15", callerId |
<Record> |
Voicemail after the last attempt | action, method="POST", maxLength="60", timeout="5", finishOnKey="*", playBeep="true", fileFormat="mp3", redirect="true", callbackUrl |
<Redirect> |
Every hop of the hold loop | method="POST" |
<Hangup> |
Ending the call after connection or voicemail | — |
| Symptom | Likely cause | Fix |
|---|---|---|
| Caller hears the greeting, then silence for the whole hold cycle | HOLD_MUSIC_URL is not reachable from the public internet, or the clip is much shorter than HOLD_WAIT_SECS — <Play loop="1"> finishes and <Wait> holds the rest in silence |
Host the clip on a public HTTPS URL and use one roughly as long as HOLD_WAIT_SECS, or shorten HOLD_WAIT_SECS. |
Hold is silent even though HOLD_MUSIC_URL is blank on purpose |
.env sets the variable to an empty string |
An empty value now falls back to the built-in beep; if you edited the file, confirm the line is HOLD_MUSIC_URL= and restart the server. |
| Every caller reaches voicemail without any phone ringing | No agent registered and both AGENT_NUMBER and FROM_NUMBER are empty, so next_agent_number() returns nothing and each pass through /queue-try-agent burns an attempt without dialling |
Register an agent with POST /agents, or set AGENT_NUMBER in .env and restart. |
| Agent answers, talks, and the caller is dropped back into hold music | The action request for <Dial> did not arrive or did not report completed — commonly a stale base URL after an ngrok restart mid-call |
Restart the server and re-point the Vobiz application at the new tunnel URL, or set PUBLIC_URL to a stable HTTPS address. Check the /dial-complete log line for the status Vobiz sent. |
| Calls connect but the agent sees an unexpected caller ID | callerId on <Dial> is FROM_NUMBER, which is empty or is not a number your account is authorised to present |
Set FROM_NUMBER to a Vobiz DID on your account in E.164 format. |
/queue/metrics always reports abandoned: 0 |
The application's Hangup URL is not set, so /hangup never fires and waiting callers are never marked abandoned |
Set the Hangup URL to <base-url>/hangup on the Vobiz application. |
Voicemail is recorded but /voicemail-done logs Duration=0s |
The final duration and file URL arrive on the callbackUrl request (RecordStop), which this app handles in /voicemail-file |
Read RecordingDuration, RecordFile/RecordUrl from the /voicemail-file callback. See Record. |
| Registered agents and metrics vanish | All state is in memory, so any restart clears it; running Uvicorn with multiple workers also gives each worker its own copy | Keep a single worker for the example, and move the store to Redis or Postgres for anything longer-lived. |
| Startup fails while opening the ngrok tunnel | No NGROK_AUTH_TOKEN, or a tunnel from a previous run is still open |
Add the token to .env, close the old tunnel, or set PUBLIC_URL and skip ngrok entirely. |
- The webhook endpoints are unauthenticated. Anything that can reach your tunnel can drive the queue endpoints. Validate that requests really come from Vobiz before trusting them — see validating callbacks — and serve the app over HTTPS.
- The agent API is management surface.
POST /agentsandDELETE /agents/{number}change where live calls are routed. Put them behind authentication or on a private network before running this anywhere real. - Voicemail contains personal data. Recordings and the caller's number are
personal data; the recording URL is written to the application log by
/voicemail-file. Restrict who can read those logs, and set a retention policy for the recordings. - Keep credentials out of git.
.env,*.pemand*.keyare already git-ignored. Only.env.example, with placeholder values, belongs in the repository. - ngrok URLs are public. A tunnel is reachable by anyone who learns the
hostname. Use it for development, and a controlled host with
PUBLIC_URLfor anything else.
Planned improvements to this example. Ideas and pull requests are welcome — open an issue to discuss anything here.
- Move the agent pool, attempt counters and metrics out of process into Redis (with Postgres for historical reporting) so the queue survives restarts and can run behind more than one worker.
- Add agent heartbeats with expiry, so an agent who closes their laptop stops
receiving calls without having to call
DELETE /agents/{number}. - Announce real queue position and an estimated wait, using the per-call metrics already collected, instead of only the count of available agents.
- Add priority and skill-based selection alongside the round-robin pointer, so VIP callers and language-specific requests can jump the queue.
- Offer a scheduled callback as an alternative to voicemail when the attempt budget runs out, and place the return call automatically.
- Deliver voicemail rather than only logging its URL: download the MP3, store it, and notify the team by email or chat.
- Add a test suite that asserts the XML returned by each webhook, the round-robin ordering and the cycle gate, and run it in CI.
Issues and pull requests are welcome. Please open an issue first for anything substantial, so the approach can be agreed before you write code.
Before opening a pull request:
python -m compileall server.py queue_store.py # syntax check
python server.py # boots, prints the banner
curl -s http://localhost:8000/health # returns status okKeep changes grounded in what the code actually does, keep .env.example in step
with the variables server.py reads, and use placeholder phone numbers in
examples.
Released under the MIT License © Vobiz.
MIT is permissive: you may use, modify, and redistribute this code, including in closed-source commercial products, provided the copyright notice and licence text are retained. There is no warranty. If your organisation needs a different licensing arrangement, contact piyush@vobiz.ai.
Vobiz is a programmable voice and SIP-trunking platform for voice APIs, SIP trunking, and AI voice agents. This repository is built and maintained by the Vobiz team.
Maintainer: Piyush Sahoo — piyush@vobiz.ai · LinkedIn
Questions, or want to talk through an integration? Open an issue on this repo, or reach out directly at piyush@vobiz.ai.
Useful links: Docs · API reference · Sign up