Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vobiz XML — Customer Feedback Survey

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.

License: MIT Python FastAPI Docs

Overview

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.

What you can build with it

  • Post-support CSAT calls. Fire POST /surveys/trigger from your helpdesk when a ticket is closed, and pull the day's scores from GET /surveys/export.csv each 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.

How it works

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

How an answer is tied back to a call

This is the mechanism worth reading the source for, because it is the part that is easy to get wrong.

  1. The identifier comes from Vobiz, not from you. When the call connects, Vobiz posts a form to /answer containing a CallUUID field. answer() reads it with form.get("CallUUID", "unknown") and calls store.start(call_uuid, phone), which creates an entry in SurveyStore._pending. The caller's number comes from the ?phone= query parameter that the trigger endpoint appended to the answer URL, falling back to the From form field.
  2. 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 with request.query_params.get("call_uuid", "unknown"). No cookies, no server-side session, and nothing that breaks when a second call arrives concurrently.
  3. 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.
  4. The record is finalised once, at the end. store.complete(call_uuid) pops the pending dictionary, builds a SurveyResult with a freshly generated uuid4() primary key, and files it in two places: _store[result.id] for lookup by result ID and _call_index[call_uuid] = result.id so a call UUID can be mapped back to its result. Because complete() pops, a duplicate /survey-done is 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.

Architecture

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.

Prerequisites

  • 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.
  • pip and, 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 set PUBLIC_URL instead and no tunnel is opened.
  • Outbound HTTPS access to api.vobiz.ai from wherever the server runs.

Setup

  1. Clone the repository.

    git clone https://github.com/vobiz-ai/Vobiz-Call-Survey-XML-Python.git
    cd Vobiz-Call-Survey-XML-Python
  2. Create and activate a virtual environment.

    python3 -m venv .venv
    source .venv/bin/activate        # Windows: .venv\Scripts\activate
  3. Install the dependencies.

    pip install -r requirements.txt
  4. Create your environment file.

    cp .env.example .env
  5. Fill in .env. At minimum set VOBIZ_AUTH_ID, VOBIZ_AUTH_TOKEN, and FROM_NUMBER. Find the auth ID and token on your Vobiz dashboard; see Authentication for details.

  6. Choose how Vobiz reaches you. For local development, leave PUBLIC_URL empty and optionally set NGROK_AUTH_TOKEN — a tunnel is opened at start-up and its HTTPS URL becomes BASE_URL. For a deployed environment, set PUBLIC_URL to your public HTTPS origin (no trailing slash needed; it is stripped) and ngrok is skipped entirely.

  7. Start the server and note the base URL it prints.

    python server.py

Configuration

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.

Running it

Start the service:

python server.py

You 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-…

The stored response shape

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-77a1c4b2d6e3

Read 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.csv

The 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

API and XML reference

Application-facing endpoints

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.

Vobiz webhook endpoints

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.

Questions and accepted input

# Question Valid input Stored as
Q1 On a scale of 1 to 5, how would you rate the quality of our service? 15 (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".

Vobiz XML elements used

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.

Troubleshooting

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.

Security notes

  • Credentials. VOBIZ_AUTH_ID and VOBIZ_AUTH_TOKEN are read from the environment and sent as X-Auth-ID and X-Auth-Token headers over HTTPS. .env is 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/summary have 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/trigger is 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.

Roadmap

Planned improvements to this example. Ideas and pull requests are welcome — open an issue to discuss anything here.

  • Swap SurveyStore for a database-backed implementation so responses survive a restart and multiple workers, writing each answer as it arrives and flipping a completed flag at the end, as the class docstring suggests.
  • Finalise partial surveys from the /hangup handler and expire stale _pending entries, 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 # TODO marker 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_uuid returned by the Call API at trigger time and reconcile it with the CallUUID seen on /answer, giving a single identifier from trigger to result.
  • Add a pytest suite covering SurveyStore finalisation, 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.

Contributing

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 ok

There is no automated test suite yet — adding one is on the Roadmap, and that is a good first contribution.

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

Post-call customer feedback survey built with Vobiz voice XML and Python - DTMF responses per call.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages