Deliver one-time passwords over a phone call: generate a code, place an outbound call with the Vobiz Voice API, read the code aloud digit by digit, and verify what the user types back.
Voice OTP is the fallback that keeps sign-ups and logins working when SMS does not arrive — a blocked sender ID, a carrier filter, a landline, or a user who simply never received the text. This repository is a complete, runnable implementation of that fallback: a single FastAPI service that generates the code, places the call through Vobiz, speaks the digits over Vobiz XML, and exposes a verification endpoint your application can call.
The service has two faces. The first is an internal API your own product talks to:
POST /send-otp to issue a code and trigger the call, POST /verify-otp to check
what the user typed, and GET /otp-status/{phone} to see how delivery went. The
second is the webhook surface Vobiz itself calls — /answer when the callee picks
up, /otp-choice when they press a key, and /hangup when the call ends. Keeping
both in one process means the OTP never has to travel anywhere except between your
app and the caller's ear.
Codes are six digits, valid for five minutes, single-use, and capped at three
verification attempts. Those rules live in otp_store.py behind a small interface
(generate, bind_call, verify, status), so the in-memory dictionary used here
can be swapped for Redis or a database without touching the call logic.
By the end of the setup below you will have a public HTTPS endpoint, a real call
placed to your own phone that reads a freshly generated code aloud with a pause
between each digit, and a working verify call that returns {"verified": true}.
- SMS fallback for sign-up and login — when the SMS OTP is not confirmed within a timeout, place a voice call with the same code instead of dropping the user.
- Landline and VoIP-desk verification — verify business numbers and desk phones that cannot receive text messages at all.
- High-value transaction confirmation — read a code aloud before a large transfer, a payout change, or a bank-detail update.
- Account-recovery flows — a second channel for users who have lost access to their email or authenticator app.
- Accessibility-first verification — a spoken code, repeated on demand, for users who find reading a short-lived SMS difficult.
- Delivery-agent or field-staff check-in — confirm a phone number really belongs to the person on the other end before dispatching a job.
Your application posts a phone number to /send-otp. The service generates a
six-digit code, stores it against that number with a five-minute expiry, and calls
the Vobiz create-call endpoint with an answer_url that points back at its own
/answer route, carrying the phone number as a URL-encoded query parameter.
When the callee answers, Vobiz fetches /answer and the service returns Vobiz XML.
It greets the caller, then speaks each digit inside its own <Speak> element with a
one-second <Wait> between them — a single digit in its own tag makes text-to-speech
read the numeral rather than run the digits together into a word, and the pause gives
the caller time to write it down. The whole code is read twice, then a <Gather>
offers a repeat: press 1 and /otp-choice returns the digits once more.
When the call ends Vobiz posts to /hangup, which marks the record delivered and
prunes stale entries. Separately, whenever the user types the code into your site,
your backend posts it to /verify-otp, which checks expiry, single use, and the
attempt counter before answering.
Your app ──POST /send-otp {"phone":"+15550003333"}──▶ server.py
│ store.generate() → 6 digits, 5-min TTL
│ POST api.vobiz.ai …/Call/
▼
Vobiz places the call
│
callee answers ──▶ POST /answer?phone=%2B15550003333
│ resolve OTP (CallUUID → phone fallback)
▼
<Speak> greeting + "your one-time password is"
<Speak>4</Speak><Wait length="1"/> … one per digit
<Speak> "I repeat" </Speak> + digits again
<Gather numDigits="1" executionTimeout="8">
├── 1 ──▶ POST /otp-choice → digits again → <Hangup/>
└── other / timeout ──▶ "Goodbye" → <Hangup/>
│
call ends ──▶ POST /hangup → mark_delivered() + cleanup_expired()
Your app ──POST /verify-otp {"phone","otp"}──▶ {"verified": true|false, "reason": "..."}
| File | Responsibility |
|---|---|
server.py |
FastAPI application. Hosts the app-facing API and the Vobiz webhooks, builds the Vobiz XML, triggers the outbound call, and opens the ngrok tunnel at startup. |
otp_store.py |
OTP lifecycle: generation, five-minute expiry, attempt counting, single-use marking, call binding, delivery status, and cleanup. In-memory, keyed by phone number. |
.env.example |
Template for local configuration. Copy to .env. |
requirements.txt |
Pinned runtime dependencies (FastAPI, Uvicorn, Requests, python-dotenv, pyngrok, Pydantic). |
LICENSE |
MIT licence text. |
Key helpers inside server.py:
| Function | Responsibility |
|---|---|
_trigger_vobiz_call() |
POSTs to https://api.vobiz.ai/api/v1/Account/{auth_id}/Call/ with X-Auth-ID / X-Auth-Token headers and a 10-second timeout. |
_spell_otp() |
Turns 482916 into one <Speak> element per digit separated by <Wait length="1"/>. |
_normalize_phone() |
Restores the leading + that URL decoding turns into a space. |
setup_ngrok() |
Opens an ngrok tunnel and returns the HTTPS public URL when PUBLIC_URL is not set. |
- A Vobiz account with an auth ID and auth token — sign up.
- A Vobiz DID (phone number) on that account to use as the caller ID.
- Python 3.10 or newer —
otp_store.pyuses the built-in generic syntaxdict[str, OTPRecord]andtuple[bool, str]. - pip and, ideally, a virtual environment.
- A public HTTPS URL so Vobiz can reach the webhooks. The sample opens an
ngrok tunnel automatically for local development; in a
deployed environment set
PUBLIC_URLinstead. - A phone you can answer to receive the test call.
-
Clone the repository and enter it.
git clone https://github.com/vobiz-ai/Vobiz-OTP-call-XML-Python.git cd Vobiz-OTP-call-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 configuration file.
cp .env.example .env
-
Edit
.envand fill in your credentials and DID.VOBIZ_AUTH_ID=your_vobiz_auth_id VOBIZ_AUTH_TOKEN=your_vobiz_auth_token FROM_NUMBER=+15550001111 HTTP_PORT=8000 PUBLIC_URL= NGROK_AUTH_TOKEN=
Leave
PUBLIC_URLempty for local development — the server will open an ngrok tunnel and use that..envis already listed in.gitignore; keep it there. -
Start the server.
python server.py
No Answer URL needs to be configured in the Vobiz console: this example passes its own
answer_urlandhangup_urlon every create-call request, using the public URL it resolved at startup.
Every variable the code actually reads, as loaded by python-dotenv from .env
and then os.getenv in server.py:
| Variable | Required | Default | Description |
|---|---|---|---|
VOBIZ_AUTH_ID |
Yes | (empty) | Vobiz account auth ID. Used both in the create-call URL path and in the X-Auth-ID header. |
VOBIZ_AUTH_TOKEN |
Yes | (empty) | Vobiz account auth token, sent as the X-Auth-Token header. |
FROM_NUMBER |
Yes | (empty) | Your Vobiz DID, sent as the from field on the create-call request. This is the caller ID the user sees. |
HTTP_PORT |
No | 8000 |
Port Uvicorn binds on 0.0.0.0, and the port ngrok tunnels to. |
PUBLIC_URL |
No | (empty) | Public HTTPS base URL for the webhooks. When set, ngrok is skipped entirely. A trailing slash is stripped automatically. |
NGROK_AUTH_TOKEN |
No | (empty) | ngrok auth token. Optional, but an authenticated tunnel is more stable for repeated testing. |
.env.example also ships an OTP_CODE placeholder. The current server does not read
it — every code is generated per request by OTPStore.generate(), so there is no
static fallback code to configure.
OTP behaviour is set by constants at the top of otp_store.py rather than by
environment variables:
| Constant | Value | Meaning |
|---|---|---|
OTP_LENGTH |
6 |
Digits in the generated code. |
OTP_EXPIRY_MINS |
5 |
Time-to-live from generation. |
MAX_ATTEMPTS |
3 |
Verification attempts before the code is locked. |
With the server running you should see a startup banner listing the resolved base URL and every route, followed by the ngrok tunnel URL if one was opened:
2026-01-01 12:00:00 [INFO] ngrok tunnel: https://<subdomain>.ngrok-free.app
============================================================
03 — OTP Verification Call
Send OTP : POST https://<subdomain>.ngrok-free.app/send-otp
Verify OTP : POST https://<subdomain>.ngrok-free.app/verify-otp
OTP Status : GET https://<subdomain>.ngrok-free.app/otp-status/{phone}
Answer URL : https://<subdomain>.ngrok-free.app/answer (set in Vobiz)
Hangup URL : https://<subdomain>.ngrok-free.app/hangup
============================================================
Confirm the service is healthy:
curl http://localhost:8000/health
# {"status":"ok","base_url":"https://<subdomain>.ngrok-free.app","example":"03_otp_call"}Place a real OTP call to your own number. This is the step the server exists for —
there is no separate call-placing script; /send-otp generates the code and fires
the outbound call in one request:
curl -X POST http://localhost:8000/send-otp \
-H "Content-Type: application/json" \
-d '{"phone": "+15550003333"}'{
"status": "call_initiated",
"phone": "+15550003333",
"call_uuid": "5a9fcfee-3d4c-11ef-bef9-0242ac110005",
"expires_in_seconds": 300,
"max_attempts": 3
}Your phone rings. Answer it and you hear the greeting, then the six digits one at a
time with a pause between each, then the same code repeated, then the offer to press
1 to hear it again. The server log shows the generated code, the call trigger, the
/answer hit, and finally the hangup.
Check delivery status at any time:
curl "http://localhost:8000/otp-status/+15550003333"Then verify the code you heard, exactly as your own backend would:
curl -X POST http://localhost:8000/verify-otp \
-H "Content-Type: application/json" \
-d '{"phone": "+15550003333", "otp": "482916"}'
# {"verified":true,"reason":"verified"}If you would rather place the call yourself instead of going through /send-otp,
the underlying Vobiz request is a plain HTTP POST — this is exactly what
_trigger_vobiz_call() sends:
curl -X POST "https://api.vobiz.ai/api/v1/Account/$VOBIZ_AUTH_ID/Call/" \
-H "X-Auth-ID: $VOBIZ_AUTH_ID" \
-H "X-Auth-Token: $VOBIZ_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from": "+15550001111",
"to": "+15550003333",
"answer_url": "https://<your-public-url>/answer?phone=%2B15550003333",
"answer_method": "POST",
"hangup_url": "https://<your-public-url>/hangup",
"hangup_method": "POST"
}'Note the %2B — the + must be percent-encoded in the query string, or it arrives
at /answer as a space. See Make an outbound call
in the API reference for the full request and response shape.
| Method | Path | Called by | Description |
|---|---|---|---|
POST |
/send-otp |
Your application | Body {"phone": "+15550003333"}. Generates a code and triggers the outbound call. Returns call_initiated with call_uuid, expires_in_seconds, and max_attempts. Returns 502 if the call could not be placed. |
POST |
/verify-otp |
Your application | Body {"phone": "...", "otp": "..."}. Returns {"verified": bool, "reason": str}. |
GET |
/otp-status/{phone} |
Your application | Returns status, expiry timestamp, attempts used, attempts remaining, and the bound call identifier. |
POST |
/answer |
Vobiz | Answer webhook. Reads CallUUID from the form body and phone from the query string, then returns the Vobiz XML that speaks the code. |
POST |
/otp-choice |
Vobiz | Gather action. Reads Digits from the form body and call_uuid from the query string. 1 repeats the code; anything else ends the call. |
POST |
/hangup |
Vobiz | Hangup webhook. Marks the record delivered, prunes expired records, returns OK. |
GET |
/health |
You | Liveness check; echoes the resolved base_url. |
Verification reasons returned by /verify-otp: verified, not_found, expired,
already_used, max_attempts, and invalid — N attempt(s) remaining.
Delivery statuses returned by /otp-status/{phone}: not_found, pending,
calling, delivered, failed, expired, used.
| Element | Attributes used here | Purpose |
|---|---|---|
<Response> |
— | Root element of every XML document returned to Vobiz. |
<Speak> |
voice="WOMAN", language="en-US" |
Greeting, the "I repeat" line, each individual digit, and the closing message. |
<Wait> |
length="1" |
One-second pause between digits so the caller can write them down. |
<Gather> |
action, method="POST", inputType="dtmf", numDigits="1", executionTimeout="8" |
Collects a single DTMF digit for the "press 1 to repeat" option. The nested <Speak> is the prompt. |
<Hangup> |
— | Ends the call. |
Digit spacing is produced by _spell_otp(): the code 482916 becomes six separate
<Speak> elements, each holding one digit, each followed by <Wait length="1"/>.
Isolating a digit in its own element keeps text-to-speech from reading the sequence
as a single number or word.
Full element documentation lives in the Vobiz XML reference.
| Symptom | Likely cause | Fix |
|---|---|---|
/send-otp returns 502 Failed to trigger call |
VOBIZ_AUTH_ID / VOBIZ_AUTH_TOKEN empty or wrong, FROM_NUMBER not a DID on the account, or the 10-second request timeout in _trigger_vobiz_call() elapsed. |
Check the values in .env, confirm the DID belongs to your account, and read the exception text in the Failed to trigger call log line — it carries the upstream error. |
| The call connects but says "we could not retrieve your one-time password" | /answer could not resolve a record. The create-call response returns api_id and request_uuid, neither of which equals the CallUUID Vobiz posts to /answer, so resolution falls through to the phone query parameter — and that record has already expired, or the parameter was lost. |
Make sure answer_url still carries ?phone=%2B..., and answer the call within the five-minute OTP_EXPIRY_MINS window. |
Phone number arrives at /answer with a leading space instead of + |
+ in a query string decodes to a space. |
_normalize_phone() repairs this automatically. When constructing the URL yourself, percent-encode the + as %2B. |
/verify-otp returns not_found for a code the user definitely heard |
OTPStore is a plain in-process dictionary. Restarting the server, or running more than one worker process, loses or splits the records. |
Keep the single-process python server.py entry point for testing; move to a shared store before running multiple workers. |
/verify-otp returns expired or max_attempts |
The code is older than OTP_EXPIRY_MINS (5), or three attempts have already been counted for it. Note that every attempt increments the counter, including wrong ones. |
Call /send-otp again to issue a fresh code. Adjust the constants in otp_store.py if your flow needs a different window. |
| Server starts but Vobiz never reaches the webhooks | BASE_URL is captured once at startup. If the ngrok tunnel restarted, the URL baked into answer_url is stale. |
Restart server.py after the tunnel changes, or set PUBLIC_URL to a stable HTTPS address. |
| ngrok fails to start or the tunnel drops repeatedly | No NGROK_AUTH_TOKEN, or an existing agent session is already running. |
Set NGROK_AUTH_TOKEN in .env, close other ngrok sessions, or set PUBLIC_URL and bypass ngrok entirely. |
Caller presses 1 but the code is not repeated |
/otp-choice resolves the record by the call_uuid query parameter alone; if the value bound at call time does not match, no code is found and the call ends politely. |
Confirm the Digits field is arriving in the form body, and see the roadmap item on binding the CallUUID from the answer webhook. |
A voice OTP is an authentication factor. Treat this service as part of your auth surface, not as a demo endpoint.
- The generated code is logged.
server.pywritesOTP generated for {phone}: {otp}atINFO. That is deliberate for local testing and unacceptable anywhere else — remove or redact that log line before deploying, and check that your log shipper is not retaining it. - Protect
/send-otp. As written it is unauthenticated and every request places a billable outbound call. Put your own authentication in front of it, and never expose it directly to the public internet. - Rate limit before you ship. There is no throttling in this example. Cap requests per phone number, per account, and per source IP, add a cooldown between consecutive codes to the same number, and reject a phone number that has already been called several times within a short window.
- Guard against toll fraud. Outbound voice is chargeable, which makes an open OTP endpoint an attractive target for pumping traffic towards expensive destinations. Allowlist the country codes you actually serve, set a per-account daily call cap, and alert on unusual volume or on a spike of unanswered calls.
- Keep the expiry and attempt limits short. The five-minute TTL, single-use flag,
and three-attempt cap in
otp_store.pyexist to bound a brute-force window. Do not widen them casually; six digits with unlimited attempts is not a secret. - Do not return the code. No endpoint here echoes the OTP back over HTTP, and
/otp-statusdeliberately returns delivery metadata only. Keep it that way. - Webhooks are unauthenticated.
/answer,/otp-choice, and/hangupaccept any POST. Serve them over HTTPS on an unguessable path, restrict them to Vobiz source addresses where you can, and treat everything in the form body as untrusted input. - Keep credentials out of the repository.
.envis already in.gitignore. Use your platform's secret manager in a deployed environment rather than a file on disk, and rotate the auth token if it is ever printed or shared. - Phone numbers are personal data. They are stored in memory here and appear in logs. Apply your own retention and access rules before that reaches production.
Planned improvements to this example. Ideas and pull requests are welcome — open an issue to discuss anything here.
- Replace the in-memory
OTPStoredictionary with a Redis-backed implementation using native TTL keys, so codes survive a restart and can be shared across multiple worker processes. - Add rate limiting and a per-number cooldown to
/send-otp, plus optional authentication on the application-facing endpoints. - Bind the real
CallUUIDfrom the/answerwebhook rather than the identifier returned by the create-call response, so UUID lookup is the primary path and the phone-number fallback is only a fallback. - Reconcile delivery status against the actual hangup cause and CDR data instead
of marking every completed call as
delivered. - Retry on no-answer, busy, and failed calls with backoff and a capped attempt count, with an optional handover to a second channel.
- Add a pytest suite covering code generation, expiry, attempt counting, single-use enforcement, and the shape of the generated Vobiz XML.
- Add structured JSON logging with the code redacted, plus metrics for call-initiated, answered, verified, and expired counts.
Issues and pull requests are welcome. If you are proposing a change:
-
Open an issue first for anything larger than a fix, so we can agree the approach.
-
Keep the example readable — this repository is documentation as much as it is code.
-
Before opening a pull request, check that everything still imports and runs:
pip install -r requirements.txt python -m compileall server.py otp_store.py python server.py
Then exercise
/health,/send-otp,/otp-status/{phone}, and/verify-otpwith thecurlcommands above against a number you control. -
Never commit a
.env, a real credential, or a real subscriber phone number. Use placeholder 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