Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vobiz XML — Call Queue with Hold Music

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.

License: MIT Python 3.9+ Docs

Overview

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.

What you can build with it

  • 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/metrics for average wait time, connection count and abandonment rate.
  • A test harness for hold experience: swap HOLD_MUSIC_URL and HOLD_WAIT_SECS and listen to how the wait actually sounds before rolling it out.

How it works

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-hold therefore compares attempts already made against MAX_WAIT_CYCLES. With the code default of 2, 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 reports completed, busy, failed, cancel, timeout or no-answer to the action URL. /dial-complete treats completed as a successful bridge and sends everything else back into hold. See Dial status reporting.

Architecture

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.

Prerequisites

  • A Vobiz account with a voice-enabled phone number (DID).
  • A Vobiz application whose Answer URL points at <public-url>/answer and 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.

Setup

  1. Clone the repository.

    git clone https://github.com/vobiz-ai/Vobiz-Call-Queue-XML-Python.git
    cd Vobiz-Call-Queue-XML-Python
  2. 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
  3. Create your environment file. It must sit next to server.py; the app loads the .env beside the script regardless of your working directory.

    cp .env.example .env
  4. Fill in .env. Set FROM_NUMBER to your Vobiz DID in E.164 format and AGENT_NUMBER to the handset you want rung when no agent has registered over the API. Add NGROK_AUTH_TOKEN if you are tunnelling locally. Every variable is described under Configuration.

  5. Start the server.

    python server.py

    The banner prints the base URL, the Answer URL to paste into Vobiz, and the effective MAX_WAIT_CYCLES and HOLD_WAIT_SECS.

  6. 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.

  7. Register an agent and call the number.

    curl -X POST http://localhost:8000/agents \
      -H 'Content-Type: application/json' \
      -d '{"number": "+15550003333", "name": "Ada"}'

Configuration

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.

Running it

source .venv/bin/activate
python server.py

You 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/+15550003333

Then 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
}

Reference

Vobiz webhooks

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.

Agent and queue API

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.

XML elements used

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

Troubleshooting

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.

Security notes

  • 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 /agents and DELETE /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, *.pem and *.key are 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_URL for anything else.

Roadmap

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.

Contributing

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 ok

Keep 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.

License

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.

Built by Team Vobiz

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

About

Call queue with hold music and agent retry, built with Vobiz voice XML and Python.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages