Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vobiz XML — OTP / Verification Call

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.

License: MIT Python 3.10+ Docs

Overview

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

What you can build with it

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

How it works

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": "..."}

Architecture

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.

Prerequisites

  • 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 newerotp_store.py uses the built-in generic syntax dict[str, OTPRecord] and tuple[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_URL instead.
  • A phone you can answer to receive the test call.

Setup

  1. 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
  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 configuration file.

    cp .env.example .env
  5. Edit .env and 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_URL empty for local development — the server will open an ngrok tunnel and use that. .env is already listed in .gitignore; keep it there.

  6. Start the server.

    python server.py

    No Answer URL needs to be configured in the Vobiz console: this example passes its own answer_url and hangup_url on every create-call request, using the public URL it resolved at startup.

Configuration

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.

Running it

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.

API and XML reference

HTTP endpoints

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.

Vobiz XML elements 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.

Troubleshooting

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.

Security notes

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.py writes OTP generated for {phone}: {otp} at INFO. 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.py exist 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-status deliberately returns delivery metadata only. Keep it that way.
  • Webhooks are unauthenticated. /answer, /otp-choice, and /hangup accept 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. .env is 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.

Roadmap

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

  • Replace the in-memory OTPStore dictionary 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 CallUUID from the /answer webhook 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.

Contributing

Issues and pull requests are welcome. If you are proposing a change:

  1. Open an issue first for anything larger than a fix, so we can agree the approach.

  2. Keep the example readable — this repository is documentation as much as it is code.

  3. 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-otp with the curl commands above against a number you control.

  4. Never commit a .env, a real credential, or a real subscriber phone number. Use placeholder 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

OTP and verification calls built with Vobiz voice XML and Python - reads a code back digit by digit.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages