A simple Python webhook receiver that captures Vaspian CDR (Call Detail Record) events and stores them in a Supabase database.
When a call completes on the Vaspian platform, a CDR webhook is sent to your endpoint with details about the call — direction, caller, destination, duration, and more. This app receives those webhooks and inserts them into a Supabase Postgres table with properly typed columns.
Note: This example focuses specifically on the Call CDR webhook event. Vaspian One also supports other webhook events including Call Ringing, Call Answered, Call Ended, Call Failed, and Call Recorded. The same receiver pattern can be adapted for any of these events — each has its own payload format. See the Vaspian One Webhooks documentation for the full list of available events.
- Python 3.10+
- A Supabase account (free tier works)
- A Vaspian One account with webhook access
- Cloudflare Tunnel (
cloudflared) for local testing — or any tool that gives your local server a public URL (ngrok, etc.)
Vaspian sends CDR webhooks as application/x-www-form-urlencoded POST requests. Every field arrives as a string. Here's what each call produces:
| Field | Type | Example | Description |
|---|---|---|---|
event |
string | Call CDR |
Always "Call CDR" for CDR webhooks |
id |
string | CP02DHB6XNREGRGE |
Unique call identifier |
direction |
string | Outbound |
Inbound or Outbound |
calling_number |
string | 3155652257 |
Originating phone number |
dialed_number |
string | 3155694033 |
Destination phone number |
orig_ext |
string | 188 |
Originating extension |
origination_ts |
string | 1774373567369 |
Call start (epoch milliseconds) |
answer_ts |
string | 1774373573677 |
Call answered (epoch milliseconds) |
end_ts |
string | 1774373607333 |
Call ended (epoch milliseconds) |
total_time |
string | 40 |
Duration in seconds |
is_dcid_call |
string | true |
Whether Dynamic Caller ID was used |
is_recorded |
string | true |
Whether the call was recorded |
groups |
string | Call groups involved | |
transfer_exts |
string | Extensions involved in transfers |
In your Supabase dashboard, go to the SQL Editor and run:
CREATE TABLE cdr_webhooks (
id TEXT PRIMARY KEY,
direction TEXT NOT NULL,
calling_number TEXT NOT NULL,
dialed_number TEXT NOT NULL,
orig_ext TEXT,
origination_ts TIMESTAMPTZ,
answer_ts TIMESTAMPTZ,
end_ts TIMESTAMPTZ,
total_time INTEGER,
is_dcid_call BOOLEAN DEFAULT FALSE,
is_recorded BOOLEAN DEFAULT FALSE,
groups TEXT DEFAULT '',
transfer_exts TEXT DEFAULT '',
received_at TIMESTAMPTZ DEFAULT NOW()
);
-- Allow access via the API
ALTER TABLE cdr_webhooks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow all operations" ON cdr_webhooks
FOR ALL USING (TRUE) WITH CHECK (TRUE);This is also available in schema.sql.
- Open your project in the Supabase dashboard
- Go to Settings → Data API
- Copy your Project URL and Secret key
git clone https://github.com/vaspian/vaspian-webhook-example-cdr.git
cd vaspian-webhook-example-cdr
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .envEdit .env with your Supabase credentials:
SUPABASE_URL=https://your-project-id.supabase.co
SUPABASE_KEY=your-secret-key-here
PORT=5001
python app.pyYou should see:
Starting Vaspian CDR Webhook Receiver on port 5001
* Running on http://127.0.0.1:5001
Your webhook receiver needs a public URL so Vaspian can reach it. The easiest option is Cloudflare Tunnel:
cloudflared tunnel --url http://localhost:5001This prints a URL like https://random-words.trycloudflare.com. Your webhook endpoint is:
https://random-words.trycloudflare.com/webhook
- Log into Vaspian One and go to Tools → Webhooks (direct link)
- Click Add Webhook (or edit an existing one)
- Fill in the configuration:
- Webhook Enabled — Toggle on
- Webhook Name — Give it a descriptive name
- Endpoint URL — Your public URL +
/webhook(e.g.,https://random-words.trycloudflare.com/webhook)
- Under Call Flows, check the directions you want to capture (Inbound, Outbound, and/or Internal)
- Under Events, check Call CDR
- Under Extensions, check all extensions you want to monitor (you must explicitly select each one — unchecked extensions will not trigger webhooks)
- Click Submit
Make a call (inbound or outbound) through your Vaspian system. After the call ends, you should see output like:
CDR received: CP02DHB7K413PJS5 Outbound 3155651307 -> 3155694033 (19s)
Stored CDR CP02DHB7K413PJS5 in Supabase
Check your Supabase dashboard — the record will appear in the cdr_webhooks table with all fields properly typed.
The app is a single Flask route (/webhook) that:
- Receives the form-encoded POST from Vaspian
- Validates that the
eventfield is"Call CDR" - Converts string values to proper types (epoch ms → timestamps,
"true"→ booleans, etc.) - Inserts the typed record into Supabase via the Python client
The key conversion logic is in parse_cdr() in app.py:
def parse_cdr(form: dict) -> dict:
return {
"id": form["id"],
"direction": form["direction"],
"calling_number": form["calling_number"],
"dialed_number": form["dialed_number"],
"orig_ext": form.get("orig_ext", ""),
"origination_ts": epoch_ms_to_iso(form.get("origination_ts", "")),
"answer_ts": epoch_ms_to_iso(form.get("answer_ts", "")),
"end_ts": epoch_ms_to_iso(form.get("end_ts", "")),
"total_time": int(form.get("total_time", 0)),
"is_dcid_call": to_bool(form.get("is_dcid_call", "false")),
"is_recorded": to_bool(form.get("is_recorded", "false")),
"groups": form.get("groups", ""),
"transfer_exts": form.get("transfer_exts", ""),
}This example is meant for learning and local testing. For production use, consider:
- WSGI server — Use Gunicorn or uWSGI instead of Flask's development server
- Authentication — Add a shared secret or HMAC verification to the webhook endpoint
- Retry handling — Return appropriate HTTP status codes so Vaspian can retry on failure
- Duplicate protection — The
idprimary key already prevents duplicate inserts - Monitoring — Add logging, error alerting, and health checks
MIT