WebCDR Examples
The PBX sends raw JSON data via the webcdr scheme. Unlike the JSON CDR format, webcdr delivers the entire call record — including all trunk legs, extension legs, recordings, states, and a full call log — in a single POST request at the end of the call.
The CDR examples shown in this document have been formatted for readability using the Quick Test Server at the bottom of this page. The PBX sends raw JSON — the structured layout with box-drawing characters is produced by the server's logging code, not the PBX itself.
Configuration
Set the CDR URL in the domain settings using the webcdr scheme:
webcdr://your-server:5001/cdr OR webcdrs://your-server:5001/cdr
With optional HTTP Basic Auth:
webcdr://username:password@your-server:5001/cdr OR webcdrs://username:password@your-server:5001/cdr
The resource path (e.g. /cdr) can be anything your server expects. If omitted it defaults to /.
Top-Level Fields
| Field | Type | Description |
|---|---|---|
version | string | PBX firmware version that generated this CDR |
system | string | Activation code / unique identifier for this PBX |
domain | string | The primary (canonical) name of the domain in which the call took place |
callid | string | The primary Call-ID of the call. Typically the Call-ID of the first call leg, but during transfer and pickup scenarios this may be the Call-ID of another leg |
from | string | The From field of the call in SIP header format (display name + URI). Mainly descriptive — should not be used for billing assumptions |
to | string | The To field of the call, similar to from. Indicates the other party |
cmc | string | Client Matter Code (if used) |
comment | string | Call comment (if set) |
category | string | Call category (if set) |
rating | string | Call rating (if set) |
abandoned | boolean | Whether the call was abandoned before answer |
available | integer | Number of agents available at call time (call queue) |
start | float | Unix epoch timestamp when the call was created |
connect | float | Unix epoch timestamp when the call was connected the first time. The call can get connected again later, for example when users use the PBX to make outbound calls from outside telephones |
end | float | Unix epoch timestamp when the last call leg was disconnected |
trunklegs | array | Details of trunk legs — present when a trunk was involved in the call (see below) |
extensionlegs | array | Details of extension legs — present when the call was connected to an extension (see below) |
recordings | array | Recording file details (see below) |
states | array | History of call states the call passed through (see below) |
logs | array | Full internal call event journal (see below) |
Trunk Leg Fields (trunklegs)
Legs are trunk legs when there was a trunk involved in the call. Calls associated with an extension's cell phone will have an entry in both trunklegs and extensionlegs with matching callid values.
| Field | Type | Description |
|---|---|---|
callid | string | The Call-ID for this leg |
direction | string | Direction of the call: I = inbound from trunk, O = outbound to trunk. When forking an inbound call to a user's cell phone, the cell phone leg appears as O |
from | string | Value of the From header at the time the call leg started. Note the value may change afterwards |
to | string | Value of the To header, like from but the other direction |
final_to_dialed | string | The final SIP URI actually dialed on the trunk |
remoteparty | string | Remote party of the call. For outbound calls, contains the number used for billing — often similar to to but may differ for group calls |
localparty | string | Local party of the call |
trunk | string | ID of the trunk used for the call |
trunk-name | string | Display name of the trunk |
cost | string | Cost for the call when available, defined in the rates table for the trunk |
start | float | Unix epoch — leg started |
connect | float | Unix epoch — leg answered. Not present if the call was not connected |
end | float | Unix epoch — leg ended |
code | integer | Final SIP response code (e.g. 200, 487) |
ipadr | string | IP address perceived by the PBX for this call (transport:ip:port) |
quality | string | VQ (Voice Quality) report calculated by the PBX for this leg (RFC 6035) |
dtmf | array | DTMF digits collected during this leg, each with date (epoch) and dtmf (digit) |
extension | string | If this call was associated with an extension (e.g. cell phone call), the primary name of the extension |
extension-name | string | Extension display name |
codec | string | Codec used for this call |
mos | string/integer | MOS score as reported in the trunk's MOS score table. NR = not reported; numeric values are scaled × 10 (e.g. 42 = 4.2) |
packets | string | Full SIP message trace for this leg (INVITE, 1xx, 200, ACK, BYE) |
Extension Leg Fields (extensionlegs)
Legs are extension legs when the call was connected to an extension of the PBX — typically a VoIP phone or WebRTC client in the user portal.
| Field | Type | Description |
|---|---|---|
callid | string | The Call-ID for this leg |
from | string | Value of the From header at the time the call leg started |
to | string | Value of the To header |
direction | string | Direction from the user's perspective: I = inbound, O = outbound. In a transfer scenario where another user starts a call and transfers it, the receiving user sees it as I |
extension | string | Primary account name of the extension |
extension-name | string | Extension display name |
redirect | string | Value of the To header as presented to the user's device. May differ from to due to caller-ID presentation settings in the group |
idle | string | Seconds the extension was idle between the previous call and this one. Not reported if longer than the domain's maximum idle time (e.g. agent returning in the morning) |
start | float | Unix epoch — leg started |
connect | float | Unix epoch — leg answered. Not present if the call was not connected (ringing only) |
end | float | Unix epoch — leg ended |
ipadr | string | IP address perceived by the PBX for this call (transport:ip:port) |
quality | string | VQ report calculated by the PBX for this leg (RFC 6035) |
type | string | Call type for this leg: m = missed, r = received (connected), d = dialled (outbound) |
deleted | string | true if this leg was a cancelled ring attempt (e.g. FCM push that was superseded) |
ua | string | SIP User-Agent string of the endpoint |
dtmf | array | DTMF digits collected during this leg |
codec | string | Codec used for this call |
packets | string | Full SIP message trace for this leg |
Recording Fields (recordings)
| Field | Type | Description |
|---|---|---|
id | integer | Internal recording ID |
time | float | Unix epoch when recording started |
file | string | Relative path to the recording file on the PBX |
log | string | Recording log entry (if any) |
account | string | Account number (e.g. call queue) the recording is attributed to |
account-name | string | Account display name |
extension | string | Extension that handled the call |
extension-name | string | Extension display name |
State Fields (states)
Calls pass through one or more states during their lifetime. For example, a call might hit an auto attendant, get transferred into a call queue, then timeout into an IVR node and then into a mailbox. The states array captures the full history of those transitions.
| Field | Type | Description |
|---|---|---|
type | string | The type of state (see table below) |
from | string | Value of the From header when this state was entered |
to | string | Value of the To header |
language | string | Language in use (two-letter code, e.g. en, au, de) |
start | float | Unix epoch when this state was entered |
durationivr | string | Time in ms the call spent in an IVR state (playing announcements) |
durationring | string | Time in ms the call was ringing a user |
durationtalk | string | Time in ms the call was connected to a user. Independent from trunk connected time. Includes hold time |
durationhold | string | Time in ms the call was on hold in this state |
durationidle | string | Time in ms the connected extension was idle before this call |
reason | string | Reason the state was terminated (see table below) |
account | string | Primary name of the account used in this state |
account-name | string | Account display name |
extension | string | Extension involved in this state — the connected agent in a call queue or ring group |
extension-name | string | Extension display name |
State Types
| Value | Description |
|---|---|
acd | Call queue — the call is waiting in or being handled by a queue |
attendant | Auto attendant, or a direct call to an extension (not through a group) |
mailbox | Call reached a mailbox |
extcall | User placed a regular external call. May also include special operations like PIN authentication |
hunt | Ring group call |
conference | Conference call |
orbit | Call is parked in a park orbit |
hoot | Paging call |
srvflag | Service flag was called (to change it) |
ivrnode | IVR node processing |
caller | PBX-initiated call, e.g. inviting conference participants or hotel wake-up calls |
starcode | Starcode processing, e.g. DND or call redirection |
State Termination Reasons
| Value | Description |
|---|---|
hc | Call was connected and cleared normally |
hw | User disconnected while waiting in the queue |
hr | User disconnected while ringing in the queue |
hb | User disconnected while the queue was sending a busy signal |
rw | State terminated due to wait timeout in the queue |
rr | State ended because the call rang too long in the queue |
ra | Call redirected because it was anonymous |
user | User pressed a DTMF key that triggered an action |
soap | External server response triggered a redirection or termination |
missed | Call was missed in the auto attendant |
Log Entry Fields (logs)
The logs array is the complete internal PBX event journal for the call, ordered chronologically.
| Field | Type | Description |
|---|---|---|
t | float | Unix epoch timestamp of the event |
c | string | Component: APP, SIP, MEDIA, TRUNK, BILL |
l | integer | Log level: 4=Error, 5=Warning, 6=Info, 7=Debug, 8=Verbose, 9=Trace |
m | string | Log message |
Log Components
| Code | Component |
|---|---|
APP | Application layer — call routing, call queue, state machine |
SIP | SIP signalling layer |
MEDIA | Media/RTP layer — codecs, ICE, DTLS, SRTP, passthrough, transcoding |
TRUNK | Trunk and dial plan processing |
BILL | Billing and charge events |
Response Format
After processing a CDR, your server must return a JSON response. To acknowledge receipt with no recording upload:
{"upload": []}
To instruct the PBX to POST a recording file to your server:
{
"upload": [
{
"file": "recordings/domain/400/20260318-111618-i-0433337285.wav",
"url": "http://your-server:5001/recordings/upload/rec-1.wav"
}
]
}
Scenario 1 — Outbound Call via Trunk
Extension 500 (APP Xiaomi) dials external number 132221. The call connects via the "Vodia Out" trunk and lasts approximately 2.5 seconds.
Call Flow Timeline
| Event | Timestamp | Details |
|---|---|---|
| Call initiated | 1773802408.253776 | Extension 500 dials 132221 |
| Call connected | 1773802411.431461 | ~3s ring time |
| Call ended | 1773802413.945009 | ~2.5s talk time |
Structure
| Array | Count | Notes |
|---|---|---|
trunklegs | 1 | Outbound (O) via "Vodia Out" trunk |
extensionlegs | 1 | Inbound (I) from extension 500 |
recordings | 0 | — |
states | 1 | acd state with ring/talk durations |
Raw CDR
════════════════════════════════════════════════════════════════
│ version 70.1.beta
│ system 7de9c3e304fa3eecdf80715d11747f89
│ domain phones.pbx70.vodia-teams.com
│ callid ftWLAXqk7nP5@pbx
│ from "APP Xiaomi" <sip:500@phones.pbx70.vodia-teams.com>
│ to "132221" <sip:132221@phones.pbx70.vodia-teams.com>
│ cmc (empty)
│ comment (empty)
│ category (empty)
│ rating (empty)
│ abandoned (empty)
│ available (empty)
│ start 1773802408.253776
│ connect 1773802411.431461
│ end 1773802413.945009
════════════════════════════════════════════════════════════════
┌─ trunkleg[0] ───────────────────────────────────────────────────
│ callid xuatl4t@pbx
│ direction O
│ from "APP Xiaomi" <sip:500@phones.pbx70.vodia-teams.com>
│ to "132221" <sip:132221@phones.pbx70.vodia-teams.com>
│ final_to_dialed sip:132221@sbc.vodia-teams.com;user=phone
│ remoteparty 132221
│ localparty 500
│ trunk 4
│ trunk-name Vodia Out
│ cost (empty)
│ start 1773802408.259025
│ connect 1773802411.431128
│ end 1773802413.943609
│ code 200
│ ipadr udp:159.65.129.0:5060
│ quality
│ VQSessionReport: CallTerm
│ LocalMetrics:
│ Timestamps:START=2026-03-18T02:53:31Z STOP=2026-03-18T02:53:33Z
│ CallID:xuatl4t@pbx
│ SessionDesc:PT=8 PD=PCMA SR=8000 FD=20 FO=0 FPP=1 PPS=50 PLC=3
│ x-SIPmetrics:SVA=RG SRD=196 SFC=0
│ x-SIPterm:SDC=OK SDR=OR
│ x-Lost:i=5,z=2
│ x-Jitter:i=5,m=0,b=Ag==
│ dtmf (empty)
│ extension 500
│ extension-name APP Xiaomi
│ codec (empty)
│ mos NR
└─────────────────────────────────────────────────────────────────
┌─ extensionleg[0] ───────────────────────────────────────────────
│ callid ftWLAXqk7nP5@pbx
│ direction I
│ extension 500
│ extension-name APP Xiaomi
│ idle 79
│ start 1773802408.250148
│ connect 1773802411.431750
│ end 1773802413.850668
│ ipadr tls:210.56.148.31:39140
│ type d
│ ua VodiaPhone2/1.0.24 Android/16
└─────────────────────────────────────────────────────────────────
┌─ state[0] ──────────────────────────────────────────────────────
│ type acd
│ start 1773802408.260882
│ durationivr (empty)
│ durationring 3171
│ durationtalk 2513
│ durationhold (empty)
│ durationidle 78797
│ account 400
│ account-name CQ 1 Google
│ extension 500
│ extension-name APP Xiaomi
└─────────────────────────────────────────────────────────────────
Scenario 2 — Inbound Call Queue Call with Recording
An inbound call arrives via the "Vodia Out" trunk, is queued in call queue 400 (CQ 1 Google), rings extension 501 (App Samsung), is answered and recorded.
Call Flow Timeline
| Event | Timestamp | Details |
|---|---|---|
| Call arrives at trunk | 1773803761.027467 | Inbound from +60433337285 |
| Call queue connects | 1773803761.030508 | Queue 400 — agent 501 available |
| Agent 501 rings | 1773803774.708298 | Ring phase begins |
| Agent 501 answers | 1773803776.771537 | Talk begins |
| Call ends | 1773803786.776693 | ~8s talk time |
Structure
| Array | Count | Notes |
|---|---|---|
trunklegs | 1 | Inbound (I) from external number |
extensionlegs | 2 | One answered, one cancelled FCM push attempt (deleted=true) |
recordings | 1 | Recorded to call queue 400 |
states | 1 | acd state with full queue breakdown |
Key Concepts
Multiple extension legs are normal for call queue calls. The PBX may attempt to reach an extension via multiple paths simultaneously (e.g. registered SIP client + FCM mobile push). Each attempt generates its own leg. Cancelled attempts have deleted=true and no connect timestamp.
Raw CDR
════════════════════════════════════════════════════════════════
│ version 70.1.beta
│ system 7de9c3e304fa3eecdf80715d11747f89
│ domain phones.pbx70.vodia-teams.com
│ callid w58kppt@pbx
│ from "Bob Smithr" <sip:+60433337285@phones.pbx70.vodia-teams.com>
│ to "V70 Trunk" <sip:+60272010747@phones.pbx70.vodia-teams.com>
│ abandoned false
│ available 1
│ start 1773803761.029485
│ connect 1773803761.030508
│ end 1773803786.776693
═══════════════════ ═════════════════════════════════════════════
┌─ trunkleg[0] ───────────────────────────────────────────────────
│ callid w58kppt@pbx
│ direction I
│ remoteparty +60433337285
│ localparty "V70 Trunk" <sip:+60272010747@phones.pbx70.vodia-teams.com>
│ trunk 4
│ trunk-name Vodia Out
│ start 1773803761.027467
│ connect 1773803761.032170
│ end 1773803784.861931
│ code 200
│ ipadr udp:159.65.129.0:5060
│ extension 501
│ extension-name App Samsung
│ mos 42
└─────────────────────────────────────────────────────────────────
┌─ extensionleg[0] ───────────────────────────────────────────────
│ callid qfjxve6@pbx
│ direction O
│ extension 501
│ extension-name App Samsung
│ redirect "V70 Trunk" <sip:0272010747@phones.pbx70.vodia-teams.com>
│ idle 355
│ start 1773803774.708298
│ connect 1773803776.771537
│ end 1773803784.770524
│ ipadr tls:210.56.148.31:58704
│ type r
│ deleted (empty)
│ ua Safari/26.0.1 Macintosh/70.1.beta
└─────────────────────────────────────────────────────────────────
┌─ extensionleg[1] ───────────────────────────────────────────────
│ callid 6l87ldh@pbx
│ direction O
│ extension 501
│ extension-name App Samsung
│ start 1773803774.716104
│ connect (empty)
│ end 1773803786.774144
│ type r
│ deleted true
│ ua VodiaPhone2/1.0.24 Android/16
└─────────────────────────────────────────────────────────────────
┌─ recording[0] ──────────────────────────────────────────────────
│ id 496
│ time 1773803761.027467
│ file recordings/phones.pbx70.vodia-teams.com/400/20260318-111618-i-0433337285.wav
│ account 400
│ account-name CQ 1 Google
│ extension 501
│ extension-name App Samsung
└─────────────────────────────────────────────────────────────────
┌─ state[0] ──────────────────────────────────────────────────────
│ type acd
│ start 1773803761.033621
│ durationivr 13689
│ durationring 3961
│ durationtalk 8093
│ durationhold (empty)
│ durationidle 356437
│ reason hc
│ account 400
│ account-name CQ 1 Google
│ extension 501
│ extension-name App Samsung
└─────────────────────────────────────────────────────────────────
Scenario 3 — Inbound Call via Auto Attendant → Call Queue with DTMF
An inbound call arrives at Auto Attendant 100 (AA 1). The caller navigates the menu using DTMF (pressing 4, 4, then 3), which routes the call to call queue 400 (CQ 1 Google). Agent 501 (App Samsung) answers the call, which is recorded.
Call Flow Timeline
| Event | Timestamp | Details |
|---|---|---|
| Call arrives at trunk | 1773804961.358170 | Inbound from +60433337285 |
| Auto Attendant 100 answers | 1773804961.361828 | IVR announcements play |
DTMF 4 pressed | 1773804967.154 | Attendant navigating menu |
DTMF 4 pressed again | 1773804982.434 | Attendant navigating menu |
DTMF 3 pressed | 1773804985.954 | Routes to call queue 400 |
| Call queue 400 starts ringing agent | 1773804998.836993 | Agent 501 selected |
| Agent 501 answers | 1773805007.677447 | Talk begins, recording starts |
| Call ends | 1773805015.898469 | ~6.5s talk time |
Structure
| Array | Count | Notes |
|---|---|---|
trunklegs | 1 | Inbound (I) — DTMF digits captured in dtmf array |
extensionlegs | 1 | Agent 501 (Android, answered) |
recordings | 1 | Recorded to call queue 400 |
states | 2 | attendant (AA 1) then acd (CQ 1 Google) |
Key Concepts
Multiple states reflect the call's journey. The states array is ordered chronologically — here attendant (state[0]) precedes acd (state[1]), showing the call moved from the auto attendant into the call queue after the caller pressed DTMF 3.
DTMF capture — digits pressed by the caller are recorded in the trunk leg's dtmf array with the epoch timestamp of each keypress. This is useful for auditing IVR navigation.
Codec transcoding — the trunk leg uses PCMA while the extension uses Opus. The PBX handles transcoding automatically; this is visible in the logs as Different Codecs (local PCMA/8000, remote opus/48000), switching to transcoding.
Raw CDR
════════════════════════════════════════════════════════════════
│ version 70.1.beta
│ system 7de9c3e304fa3eecdf80715d11747f89
│ domain phones.pbx70.vodia-teams.com
│ callid ef3r8xh@pbx
│ from "Bob Smithr" <sip:+60433337285@phones.pbx70.vodia-teams.com>
│ to "V70 Trunk" <sip:+60272010747@phones.pbx70.vodia-teams.com>
│ abandoned false
│ available 1
│ start 1773804961.360803
│ connect 1773804961.361818
│ end 1773805015.898469
════════════════════════════════════════════════════════════════
┌─ trunkleg[0] ───────────────────────────────────────────────────
│ callid ef3r8xh@pbx
│ direction I
│ remoteparty +60433337285
│ localparty "V70 Trunk" <sip:+60272010747@phones.pbx70.vodia-teams.com>
│ trunk 4
│ trunk-name Vodia Out
│ start 1773804961.358170
│ connect 1773804962.362188
│ end 1773805015.844658
│ code 200
│ ipadr udp:159.65.129.0:5060
│ quality
│ VQSessionReport: CallTerm
│ LocalMetrics:
│ Timestamps:START=2026-03-18T03:36:02Z STOP=2026-03-18T03:36:55Z
│ CallID:ef3r8xh@pbx
│ SessionDesc:PT=8 PD=PCMA SR=8000 FD=20 FO=160 FPP=1 PPS=50 PLC=3
│ x-SIPterm:SDC=OK SDR=AN
│ PacketLoss:NLR=0.0 JDR=0.0
│ QualityEst:MOSLQ=4.2 MOSCQ=4.2
│ x-Lost:i=5,z=12
│ x-Jitter:i=5,m=0,b=AiIjIiIi
│ dtmf [{"date":"1773804967.154","dtmf":"4"},{"date":"1773804982.434","dtmf":"4"},{"date":"1773804985.954","dtmf":"3"}]
│ extension 501
│ extension-name App Samsung
│ codec (empty)
│ mos 42
└─────────────────────────────────────────────────────────────────
┌─ extensionleg[0] ───────────────────────────────────────────────
│ callid pzn3tsk@pbx
│ direction O
│ extension 501
│ extension-name App Samsung
│ redirect "V70 Trunk" <sip:0272010747@phones.pbx70.vodia-teams.com>
│ idle 1223
│ start 1773804998.836993
│ connect 1773805007.677447
│ end 1773805015.895355
│ ipadr tls:210.56.148.31:55880
│ quality
│ VQSessionReport: CallTerm
│ LocalMetrics:
│ Timestamps:START=2026-03-18T03:36:47Z STOP=2026-03-18T03:36:55Z
│ CallID:pzn3tsk@pbx
│ SessionDesc:PT=97 PD=opus SR=8000 FD=20 FO=7 FPP=1 PPS=50 PLC=3
│ x-SIPmetrics:SVA=RG SRD=3169
│ x-SIPterm:SDC=OK SDR=OR
│ QualityEst:MOSLQ=5.0 MOSCQ=4.5
│ x-Lost:i=5,z=5
│ x-Jitter:i=5,m=0,c=AFMQ
│ type r
│ deleted (empty)
│ ua VodiaPhone2/1.0.24 Android/16
└─────────────────────────────────────────────────────────────────
┌─ recording[0] ──────────────────────────────────────────────────
│ id 497
│ time 1773804961.358170
│ file recordings/phones.pbx70.vodia-teams.com/400/20260318-113649-i-0433337285.wav
│ account 400
│ account-name CQ 1 Google
│ extension 501
│ extension-name App Samsung
└─────────────────────────────────────────────────────────────────
┌─ state[0] ──────────────────────────────────────────────────────
│ type attendant
│ language au
│ start 1773804961.361828
│ durationivr 19122
│ durationring (empty)
│ durationtalk 5471
│ durationhold (empty)
│ durationidle (empty)
│ reason (empty)
│ account 100
│ account-name AA 1
│ extension (empty)
│ extension-name (empty)
└─────────────────────────────────────────────────────────────────
┌─ state[1] ──────────────────────────────────────────────────────
│ type acd
│ language au
│ start 1773804985.959362
│ durationivr 12883
│ durationring 10593
│ durationtalk 6462
│ durationhold (empty)
│ durationidle 1224665
│ reason hc
│ account 400
│ account-name CQ 1 Google
│ extension 501
│ extension-name App Samsung
└─────────────────────────────────────────────────────────────────
Additional Information
Timestamps
All start, connect, and end fields are Unix epoch floats (seconds since 1970-01-01T00:00:00Z) with microsecond precision. To convert to a readable UTC time:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(float("1773803761.029485"), tz=timezone.utc)
# → 2026-03-18 03:16:01 UTC
Voice Quality Metrics (quality)
Each leg includes a VQ report in RFC 6035 / RTCP-XR format. Key fields:
| Metric | Description |
|---|---|
QualityEst:MOSLQ | Listening quality MOS score (1.0–5.0, higher is better) |
QualityEst:MOSCQ | Conversational quality MOS score |
PacketLoss:NLR | Near-side packet loss ratio |
Delay:RTD | Round-trip delay (ms) |
Delay:IAJ | Inter-arrival jitter (ms) |
x-SIPterm:SDR | Disconnect reason: OR = outbound release, AN = answered normally |
MOS Values
The mos field in trunk legs is an integer scaled × 10 (e.g. 42 = MOS 4.2). NR means not reported.
State Durations
All duration fields (durationivr, durationring, durationtalk, durationhold, durationidle) are in milliseconds. Divide by 1000 to get seconds.
Quick Test Server
The following Python Flask script can be used to quickly receive and inspect webcdr POST requests during development. It prints every field in a structured, readable format to the console.
Requirements
pip install flask gunicorn
Usage
- Save the script below as
webcdr_server.py - Run it with gunicorn:
gunicorn -w 1 -b 0.0.0.0:5001 webcdr_server:app - Configure the PBX CDR URL as:
webcdr http://your-server-ip:5001/cdr
Use gunicorn to run this server — not python webcdr_server.py. Flask's built-in dev server (Werkzeug) does not handle binary file uploads reliably and will fail when the PBX attempts to POST recording files to the /upload endpoint.
The -w 1 flag (single worker) is required. Multiple workers would split requests across separate processes, causing stream handling issues with binary uploads.
Script
import os
from flask import Flask, request, jsonify
import logging
import json
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 MB
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
DIV = "═" * 64
DIV2 = "─" * 64
W = 18
def section(title):
return f"\n ┌─ {title} {'─' * (62 - len(title))}"
def field(k, v):
return f" │ {k:<{W}} {v}"
def block_field(k, text):
lines = [f" │ {k}"]
for l in str(text).splitlines():
lines.append(f" │ {l}")
return "\n".join(lines)
def end_section():
return f" └─{DIV2}"
def log_obj(label, i, obj):
lines = [section(f"{label}[{i}]")]
for k, v in obj.items():
if v is None or v == "" or v == []:
lines.append(field(k, "(empty)"))
elif isinstance(v, list):
lines.append(field(k, json.dumps(v)))
elif isinstance(v, str) and ("\n" in v or "\r" in v):
lines.append(block_field(k, v))
else:
lines.append(field(k, v))
lines.append(end_section())
app.logger.info("\n".join(lines))
@app.route('/cdr', methods=['POST'])
def vodia_cdr():
try:
data = request.get_json(force=True)
lines = [f"\n{DIV}"]
top_fields = ["version", "system", "domain", "callid", "from", "to",
"cmc", "comment", "category", "rating", "abandoned",
"available", "start", "connect", "end"]
for k in top_fields:
if k in data:
lines.append(field(k, data[k] if data[k] != "" else "(empty)"))
lines.append(DIV)
app.logger.info("\n".join(lines))
for i, leg in enumerate(data.get('trunklegs', [])):
log_obj("trunkleg", i, leg)
for i, leg in enumerate(data.get('extensionlegs', [])):
log_obj("extensionleg", i, leg)
recordings = data.get('recordings', [])
for i, rec in enumerate(recordings):
log_obj("recording", i, rec)
for i, s in enumerate(data.get('states', [])):
log_obj("state", i, s)
logs = data.get('logs', [])
if logs:
log_lines = [section(f"logs ({len(logs)} entries)")]
for entry in logs:
t = entry.get('t', '')
c = entry.get('c', '?')
l = entry.get('l', '?')
m = entry.get('m', '')
log_lines.append(f" │ t={t} c={c:<6} l={l} {m}")
log_lines.append(end_section())
app.logger.info("\n".join(log_lines))
# Request the PBX to upload any recording files
upload = []
for rec in recordings:
f = rec.get("file", "")
if f:
filename = f.split("/")[-1]
upload_url = f"http://{request.host}/upload/{filename}"
upload.append({"file": f, "url": upload_url})
app.logger.info(f" → Requesting upload: {f}\n to: {upload_url}")
return jsonify({"upload": upload}), 200
except Exception as e:
app.logger.error(f"Error processing CDR: {e}", exc_info=True)
return jsonify({"error": "Internal server error"}), 500
@app.route('/upload/<filename>', methods=['PUT', 'POST'])
def receive_recording(filename):
try:
save_path = f"/tmp/{filename}"
raw = request.get_data(cache=False)
if not raw:
app.logger.warning(f" → Upload received but body was empty for {filename}")
return jsonify({"error": "empty body"}), 400
with open(save_path, 'wb') as f:
f.write(raw)
size_kb = len(raw) / 1024
app.logger.info(
f"\n ┌─ recording upload ──────────────────────────────────────────\n"
f" │ filename {filename}\n"
f" │ saved to {save_path}\n"
f" │ size {size_kb:.1f} KB\n"
f" └─{'─' * 64}"
)
return jsonify({"status": "ok"}), 200
except Exception as e:
app.logger.error(f"Error receiving recording: {e}", exc_info=True)
return jsonify({"error": "Upload failed"}), 500
@app.route('/health', methods=['GET'])
def health():
return jsonify({"status": "healthy"}), 200
What It Logs
- Header block — all top-level fields (
callid,from,to,start,connect,end, etc.) - Trunk legs — every field including the full VQ quality report and SIP packet trace, indented under their labels
- Extension legs — same as trunk legs
- Recordings — file path, time, account, extension
- States — full call queue / auto attendant state breakdown
- Call logs — complete internal event journal with component and log level per entry
The server returns {"upload": []} to the PBX indicating no recording files need to be fetched. See the Response Format section above to enable recording uploads.