An automated post-call telephone survey: Vobiz places the call, three DTMF questions are asked in sequence, and every answer is correlated back to the call it came from and exposed as JSON, CSV, and aggregated statistics.
Collecting structured feedback over the phone is a stubbornly manual process: someone rings a customer, reads out questions, and types the answers into a spreadsheet afterwards. This example replaces that loop with a small FastAPI service. You post a phone number to it, Vobiz dials out, a synthesised voice reads three questions, the customer answers with their keypad, and a complete response record lands in the store before the call has even hung up.
The interesting part is not the speaking — it is the correlation. A survey is not one HTTP request; it is a chain of eight webhook round-trips between Vobiz and your server, each one a separate stateless POST. The example threads a single call identifier through that whole chain so that the digit pressed during question three is written to the same record as the digit pressed during question one. survey_store.py holds partial answers in a pending map keyed by call UUID and promotes them to a finished SurveyResult only when the survey reaches its end. That pattern is the piece worth copying, whatever your questions turn out to be.
It is aimed at developers who already have a list of numbers to call — support teams measuring ticket satisfaction, operations teams running delivery follow-ups, product teams sampling churned users — and who want the results in a database rather than a voicemail box. No speech recognition is involved: answers are single DTMF digits, which keeps the flow fast, cheap, and reliable over poor-quality mobile connections.
At the end you have a running service with two faces. The Vobiz-facing side returns Vobiz XML documents that drive the call. The application-facing side gives you POST /surveys/trigger to start a survey, GET /surveys/results to read responses, GET /surveys/export.csv to download them, and GET /surveys/summary for an average rating, a recommendation rate, and an experience distribution computed on the fly.
- Post-support CSAT calls. Fire
POST /surveys/triggerfrom your helpdesk when a ticket is closed, and pull the day's scores fromGET /surveys/export.csveach evening. - Delivery and field-service follow-up. Ring the customer an hour after a job is marked complete and capture a 1–5 quality rating tied to the job's phone number.
- Post-appointment feedback for clinics and salons. A 30-second call is answered far more often than an email, and the keypad answers need no transcription.
- Churn and win-back sampling. Call a sample of lapsed accounts, ask whether they would still recommend you, and read the recommendation rate straight from
GET /surveys/summary. - Recurring NPS-style pulse checks. Question 2 is a yes/no recommend question, which gives you a promoter share per cohort when you trigger against a segment list.
- Quality assurance on outsourced call centres. Trigger a survey immediately after an agent's call ends and compare average ratings per agent by tagging the phone number you pass in.
A survey is a chain of Vobiz XML documents. Each document either speaks and gathers a digit, or speaks and redirects to the next step. The call_uuid query parameter is carried on every <Redirect> and every <Gather action="…"> URL, which is what keeps the three answers together.
POST /surveys/trigger {"phone": "+15550003333"}
│
│ Vobiz Call API → answer_url = /answer?phone=+15550003333
▼
POST /answer store.start(CallUUID, phone)
└── <Speak> intro
└── <Redirect> /survey-q1?call_uuid=…
│
POST /survey-q1
└── <Gather numDigits="1" executionTimeout="10">
└── <Speak> "Rate our service 1 to 5"
no digit → <Speak> "No response received" → <Redirect> /survey-q2
digit → action → POST /survey-q1-result store.update_answer(q1_rating)
└── <Speak> "You rated us good"
└── <Redirect> /survey-q2?call_uuid=…
│
POST /survey-q2
└── <Gather> "Would you recommend us? 1 = yes, 2 = no"
no digit → <Redirect> /survey-q3
digit → POST /survey-q2-result store.update_answer(q2_recommend)
└── <Redirect> /survey-q3?call_uuid=…
│
POST /survey-q3
└── <Gather> "Overall experience? 1 / 2 / 3"
no digit → <Redirect> /survey-done
digit → POST /survey-q3-result store.update_answer(q3_experience)
└── <Redirect> /survey-done?call_uuid=…
│
POST /survey-done store.complete(call_uuid) → SurveyResult
└── <Speak> thank you
└── <Hangup/>
│
POST /hangup logs CallUUID
This is the mechanism worth reading the source for, because it is the part that is easy to get wrong.
- The identifier comes from Vobiz, not from you. When the call connects, Vobiz posts a form to
/answercontaining aCallUUIDfield.answer()reads it withform.get("CallUUID", "unknown")and callsstore.start(call_uuid, phone), which creates an entry inSurveyStore._pending. The caller's number comes from the?phone=query parameter that the trigger endpoint appended to the answer URL, falling back to theFromform field. - The identifier is propagated in the URL, not in a session. Every subsequent XML document this server emits interpolates that value into its
<Redirect>body and its<Gather action="…">attribute — for example{BASE_URL}/survey-q1?call_uuid={call_uuid}. Vobiz simply follows the URL it is given, so the identifier travels with the call. Each handler recovers it withrequest.query_params.get("call_uuid", "unknown"). No cookies, no server-side session, and nothing that breaks when a second call arrives concurrently. - Answers accumulate in a pending map.
store.update_answer(call_uuid, "q1_rating", digit)writes into_pending[call_uuid]only if that key exists, so a stray webhook for an unknown call is dropped rather than creating a phantom record. - The record is finalised once, at the end.
store.complete(call_uuid)pops the pending dictionary, builds aSurveyResultwith a freshly generateduuid4()primary key, and files it in two places:_store[result.id]for lookup by result ID and_call_index[call_uuid] = result.idso a call UUID can be mapped back to its result. Becausecomplete()pops, a duplicate/survey-doneis a no-op and cannot produce two records for one call.
Note that the call_uuid field in the POST /surveys/trigger response is not the same identifier. _trigger_vobiz_call() returns resp.json().get("api_id", "") from the Vobiz Call API, which acknowledges the request with api_id, request_uuid, and message. The CallUUID that keys the store is issued when the call is actually answered and first appears on the /answer webhook. If you need to join the two, log both and match on the phone number and timestamp, or extend the store to record request_uuid at trigger time.
| File | Responsibility |
|---|---|
server.py |
FastAPI application. Holds the application-facing API (/surveys/*), the Vobiz webhook handlers that emit Vobiz XML, ngrok bootstrap, and the outbound call trigger _trigger_vobiz_call(). |
survey_store.py |
SurveyResult dataclass and SurveyStore. Pending-answer tracking keyed by call UUID, finalisation, lookup, CSV serialisation, and the aggregate summary. |
requirements.txt |
Pinned dependencies: FastAPI, Uvicorn, python-multipart (needed to parse the form-encoded webhook bodies), python-dotenv, pyngrok, requests, Pydantic. |
.env.example |
Template for the six environment variables the server reads. Copy to .env. |
.gitignore |
Excludes .env, virtualenvs, logs, and key material from version control. |
LICENSE |
MIT licence text. |
BASE_URL is a module-level global set once in main() — from PUBLIC_URL if provided, otherwise from the ngrok tunnel that setup_ngrok() opens. Every XML document interpolates it, so the URLs Vobiz is told to call always point back at the address Vobiz can actually reach.
- A Vobiz account with an auth ID and auth token. Sign up at vobiz.ai.
- A Vobiz phone number (DID) to use as the outbound caller ID. This is
FROM_NUMBER. - Python 3.9 or newer. The code uses PEP 585 built-in generics (
list[dict],dict[str, str]) in evaluated annotations. Python 3.11 is a good default. pipand, recommended, a virtual environment.- A public HTTPS URL. Vobiz has to reach your webhooks. In local development the server opens an ngrok tunnel for you via
pyngrok; in a deployed environment setPUBLIC_URLinstead and no tunnel is opened. - Outbound HTTPS access to
api.vobiz.aifrom wherever the server runs.
-
Clone the repository.
git clone https://github.com/vobiz-ai/Vobiz-Call-Survey-XML-Python.git cd Vobiz-Call-Survey-XML-Python -
Create and activate a virtual environment.
python3 -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate
-
Install the dependencies.
pip install -r requirements.txt
-
Create your environment file.
cp .env.example .env
-
Fill in
.env. At minimum setVOBIZ_AUTH_ID,VOBIZ_AUTH_TOKEN, andFROM_NUMBER. Find the auth ID and token on your Vobiz dashboard; see Authentication for details. -
Choose how Vobiz reaches you. For local development, leave
PUBLIC_URLempty and optionally setNGROK_AUTH_TOKEN— a tunnel is opened at start-up and its HTTPS URL becomesBASE_URL. For a deployed environment, setPUBLIC_URLto your public HTTPS origin (no trailing slash needed; it is stripped) and ngrok is skipped entirely. -
Start the server and note the base URL it prints.
python server.py
Every variable the code reads, and nothing else. All are read once at import time in server.py.
| Variable | Required | Default | Description |
|---|---|---|---|
VOBIZ_AUTH_ID |
Yes | — | Vobiz account auth ID. Sent as the X-Auth-ID header and used in the Call API path /api/v1/Account/{auth_id}/Call/. |
VOBIZ_AUTH_TOKEN |
Yes | — | Vobiz account auth token, sent as the X-Auth-Token header. Keep it out of version control. |
FROM_NUMBER |
Yes | — | Your Vobiz DID in E.164 form, used as the outbound caller ID in the Call API payload. |
HTTP_PORT |
No | 8000 |
Port Uvicorn binds on (0.0.0.0). Also the port ngrok tunnels to. |
PUBLIC_URL |
No | empty | Public HTTPS origin for webhooks. When set, ngrok is not started and this value becomes BASE_URL. A trailing slash is stripped. |
NGROK_AUTH_TOKEN |
No | empty | ngrok auth token for local development. Optional — without it pyngrok still opens an anonymous tunnel, subject to ngrok's own limits. |
The server loads .env from the repository root, and additionally falls back to a shared ../../.env (without overriding anything already set) so the example still works when checked out inside the wider Vobiz examples tree.
Start the service:
python server.pyYou should see a banner listing the endpoints, with BASE_URL resolved:
============================================================
05 — Customer Feedback Survey
Trigger call : POST https://<your-host>/surveys/trigger
Results : GET https://<your-host>/surveys/results
Summary stats : GET https://<your-host>/surveys/summary
CSV export : GET https://<your-host>/surveys/export.csv
Hangup URL : https://<your-host>/hangup
============================================================
Confirm it is healthy:
curl https://<your-host>/health{ "status": "ok", "base_url": "https://<your-host>", "example": "05_survey", "total_responses": 0 }Trigger a survey call. This repository is self-contained — the outbound call is placed by the service itself, so no separate call-placing script is required:
curl -X POST https://<your-host>/surveys/trigger \
-H 'Content-Type: application/json' \
-d '{"phone": "+15550003333"}'{ "status": "call_initiated", "phone": "+15550003333", "call_uuid": "5a9fcfee-3d4c-11ef-bef9-0242ac110005" }Answer the call and press 4, then 1, then 1. The server log follows the chain:
2026-02-18 09:40:12 [INFO] Survey call triggered — phone=+15550003333, call_uuid=5a9fcfee-…
2026-02-18 09:40:21 [INFO] Survey started — CallUUID=1b0d5f88-…, phone=+15550003333
2026-02-18 09:40:34 [INFO] Q1 answer — call=1b0d5f88-…, rating=4 (good)
2026-02-18 09:40:49 [INFO] Q2 answer — call=1b0d5f88-…, recommend=yes
2026-02-18 09:41:07 [INFO] Q3 answer — call=1b0d5f88-…, experience=excellent
2026-02-18 09:41:07 [INFO] Survey complete — {'id': '3f1c9e7a-…', 'call_uuid': '1b0d5f88-…', …}
2026-02-18 09:41:14 [INFO] Call ended — CallUUID=1b0d5f88-…
GET /surveys/results returns a list, newest first, of records produced by SurveyResult.to_dict(). The keys are deliberately question-named rather than field-named — q1_rating on the dataclass is serialised as q1_service_rating:
[
{
"id": "3f1c9e7a-8c2b-4d51-9f0e-77a1c4b2d6e3",
"call_uuid": "1b0d5f88-af2e-11ef-9c31-0242ac110004",
"phone": "+15550003333",
"q1_service_rating": "4",
"q2_recommend": "yes",
"q3_experience": "excellent",
"completed": true,
"timestamp": "2026-02-18T09:41:07.512394"
}
]q1_service_rating is the raw digit as a string ("1"–"5"). q2_recommend is "yes", "no", or "unknown". q3_experience is "excellent", "good", "needs improvement", or "unknown". Any question the caller skipped is null, because the pending dictionary never received that key. timestamp is the UTC instant the record was finalised.
Fetch one record by its id:
curl https://<your-host>/surveys/results/3f1c9e7a-8c2b-4d51-9f0e-77a1c4b2d6e3Read the aggregates:
curl https://<your-host>/surveys/summary{
"total_responses": 12,
"avg_service_rating": 4.17,
"recommend_rate_pct": 75.0,
"experience_distribution": { "excellent": 7, "good": 4, "needs improvement": 1 }
}avg_service_rating is computed only over records whose rating is a digit, and is null if none are. recommend_rate_pct is the share of all records answering "yes", so skipped answers count against it. Before any survey completes, the summary is just {"total_responses": 0}.
Download everything as CSV:
curl -O -J https://<your-host>/surveys/export.csvThe file is served as survey_results.csv, oldest first, with the header row:
id,call_uuid,phone,q1_service_rating,q2_recommend,q3_experience,completed,timestamp| Method | Path | Description |
|---|---|---|
| POST | /surveys/trigger |
Place an outbound survey call. Body {"phone": "+15550003333"}. Returns call_initiated with the Vobiz api_id, or 502 if the Call API rejects the request. |
| GET | /surveys/results |
All completed responses, newest first. |
| GET | /surveys/results/{result_id} |
One response by its generated id. 404 if unknown. |
| GET | /surveys/export.csv |
All responses as CSV, oldest first, as a file download. |
| GET | /surveys/summary |
Total responses, average rating, recommendation rate, experience distribution. |
| GET | /health |
status, base_url, example, plus the summary fields merged in. |
| Method | Path | Description |
|---|---|---|
| POST | /answer |
Answer URL. Reads CallUUID from the form and phone from the query string, starts the pending record, speaks the intro, redirects to Q1. |
| POST | /survey-q1 |
Gathers one digit for the service rating. On no input, speaks a skip notice and redirects to Q2. |
| POST | /survey-q1-result |
Stores q1_rating, reads the rating back to the caller in words, redirects to Q2. |
| POST | /survey-q2 |
Gathers one digit for the recommendation question. On no input, redirects to Q3. |
| POST | /survey-q2-result |
Stores q2_recommend as yes/no/unknown, acknowledges, redirects to Q3. |
| POST | /survey-q3 |
Gathers one digit for overall experience. On no input, redirects to /survey-done. |
| POST | /survey-q3-result |
Stores q3_experience, redirects to /survey-done. |
| POST | /survey-done |
Finalises the record, speaks the closing message, hangs up. |
| POST | /hangup |
Hangup URL. Logs the CallUUID and returns OK. |
| # | Question | Valid input | Stored as |
|---|---|---|---|
| Q1 | On a scale of 1 to 5, how would you rate the quality of our service? | 1–5 (1 = very poor, 5 = excellent) |
q1_service_rating, the raw digit |
| Q2 | Would you recommend us to a friend or colleague? | 1 = yes, 2 = no |
q2_recommend: yes / no / unknown |
| Q3 | How would you describe your overall experience? | 1 = excellent, 2 = good, 3 = needs improvement |
q3_experience: the matching label, else unknown |
Any digit outside the listed range is stored as unknown for Q2 and Q3; for Q1 the raw digit is stored regardless, and only the spoken confirmation falls back to "unknown".
| Element | How this example uses it |
|---|---|
<Speak voice="WOMAN" language="en-US"> |
Every prompt, confirmation, skip notice, and the closing message. |
<Gather action="…" method="POST" inputType="dtmf" numDigits="1" executionTimeout="10"> |
Collects a single keypad digit per question. numDigits="1" means the answer submits as soon as one key is pressed; executionTimeout="10" gives the caller ten seconds. The <Speak> prompt sits inside the <Gather> so a caller can barge in and answer early. |
<Redirect method="POST"> |
Chains one step to the next, and provides the fall-through path when a <Gather> times out — anything after the <Gather> executes only if no digit was collected. |
<Hangup/> |
Ends the call after the thank-you message. |
The call_uuid query parameter appears on every <Redirect> target and every <Gather action> URL; that is what carries call identity across the stateless webhook chain. Full element documentation is in the Vobiz XML reference.
| Symptom | Likely cause | Fix |
|---|---|---|
POST /surveys/trigger returns 502 Failed to trigger call |
_trigger_vobiz_call() raised. Usually wrong VOBIZ_AUTH_ID/VOBIZ_AUTH_TOKEN, a FROM_NUMBER that is not a DID on the account, or a non-E.164 destination. The 10-second requests timeout also surfaces here. |
Check the detail string in the response — it carries the underlying exception. Verify the credentials and confirm FROM_NUMBER is in E.164 form. |
| The call connects but nothing is spoken, and the caller hears silence or a failure tone | Vobiz could not fetch the answer URL. BASE_URL is empty, points at localhost, or the ngrok tunnel died and the URL Vobiz was handed is stale. |
Read the base URL from the start-up banner or GET /health, and confirm it is publicly reachable. Restart the server after any tunnel change so freshly triggered calls get the new URL. Prefer setting PUBLIC_URL outside local development. |
Questions play correctly but every answer is null in the stored record |
The answer arrived for a call UUID that is not in _pending. update_answer() silently ignores unknown keys. This happens when /answer never ran (the call went straight into a question) or the call_uuid query parameter was lost. |
Confirm the Survey started — CallUUID=… log line appears before the Q1 log line, and that the call_uuid in the answer logs matches it. If it reads unknown, the parameter was dropped from a redirect URL. |
/survey-done logs nothing and no record appears in /surveys/results |
store.complete() returned None because the pending entry was already popped — a duplicate /survey-done, or a call that reached the end twice. |
This is a safe no-op; the first /survey-done already produced the record. Look for the earlier Survey complete line and the record in GET /surveys/results. |
| Results vanish after a restart, or a load-balanced deployment loses answers mid-survey | SurveyStore is an in-memory dictionary in a single process. Nothing is persisted, and a second worker has its own empty store. |
Run a single Uvicorn worker while evaluating. For anything beyond a demo, replace the store with a database — see the Roadmap. |
| A call that drops halfway through never produces a record | Abandoned surveys stay in _pending forever; only /survey-done finalises. /hangup just logs. |
Expect fewer records than triggered calls. To capture partial responses, finalise from the /hangup handler as well. |
RuntimeError: Form data requires "python-multipart" on any webhook |
Dependencies were installed incompletely; the webhook bodies are form-encoded. | Re-run pip install -r requirements.txt inside the active virtual environment. |
DeprecationWarning: datetime.datetime.utcnow() is deprecated on Python 3.12+ |
SurveyStore.complete() calls datetime.utcnow(). |
Harmless — timestamps are still correct UTC. Switch to datetime.now(timezone.utc) if you want the warning gone. |
- Credentials.
VOBIZ_AUTH_IDandVOBIZ_AUTH_TOKENare read from the environment and sent asX-Auth-IDandX-Auth-Tokenheaders over HTTPS..envis listed in.gitignore; keep it there and use your platform's secret manager in deployment. Never commit a real token. - The results endpoints are open.
/surveys/results,/surveys/results/{id},/surveys/export.csv, and/surveys/summaryhave no authentication in this example, and the first three return customer phone numbers. Put them behind an API key, session auth, or a private network before exposing the service. /surveys/triggeris open too. Anyone who can reach it can place calls billed to your account. Authenticate it and rate-limit it.- Webhook exposure. The Vobiz-facing routes accept unauthenticated POSTs. Because answers are only accepted for call UUIDs already in
_pending, a forged webhook cannot invent a record, but it could overwrite an in-flight answer if the call UUID is guessed. Restrict inbound traffic to Vobiz where your platform allows it. - Personal data. Stored records contain a phone number and satisfaction answers. Treat the CSV export as personal data: serve it over HTTPS only, and apply whatever retention policy your jurisdiction requires.
- Recording. This example does not record audio. Only DTMF digits are captured.
Planned improvements to this example. Ideas and pull requests are welcome — open an issue to discuss anything here.
- Swap
SurveyStorefor a database-backed implementation so responses survive a restart and multiple workers, writing each answer as it arrives and flipping acompletedflag at the end, as the class docstring suggests. - Finalise partial surveys from the
/hanguphandler and expire stale_pendingentries, so a caller who drops out after question one still yields a record instead of leaking memory. - Push completed results to a CRM or analytics platform at the
# TODOmarker in/survey-done, with a configurable outbound webhook and retry on failure. - Move the questions, prompts, and digit-to-label maps out of the handlers into a configuration file, so a new survey does not require editing Python.
- Add NPS-style scoring on top of the existing answers — a 0–10 variant of Q1 with promoter, passive, and detractor bucketing in
summary(). - Record the
request_uuidreturned by the Call API at trigger time and reconcile it with theCallUUIDseen on/answer, giving a single identifier from trigger to result. - Add a pytest suite covering
SurveyStorefinalisation, skipped answers, CSV output, and the XML each webhook returns. - Add authentication and rate limiting on
/surveys/*, plus structured JSON logging and basic metrics for triggered, completed, and abandoned surveys.
Issues and pull requests are welcome. If you are changing behaviour, please describe the call flow you tested against and include the relevant server log lines.
Before opening a pull request:
pip install -r requirements.txt
python -m compileall server.py survey_store.py # syntax check
python server.py # boots and prints the banner
curl http://localhost:8000/health # returns status okThere is no automated test suite yet — adding one is on the Roadmap, and that is a good first contribution.
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