Skip to main content

WebCDR Examples

tip

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.

note

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

FieldTypeDescription
versionstringPBX firmware version that generated this CDR
systemstringActivation code / unique identifier for this PBX
domainstringThe primary (canonical) name of the domain in which the call took place
callidstringThe 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
fromstringThe From field of the call in SIP header format (display name + URI). Mainly descriptive — should not be used for billing assumptions
tostringThe To field of the call, similar to from. Indicates the other party
cmcstringClient Matter Code (if used)
commentstringCall comment (if set)
categorystringCall category (if set)
ratingstringCall rating (if set)
abandonedbooleanWhether the call was abandoned before answer
availableintegerNumber of agents available at call time (call queue)
startfloatUnix epoch timestamp when the call was created
connectfloatUnix 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
endfloatUnix epoch timestamp when the last call leg was disconnected
trunklegsarrayDetails of trunk legs — present when a trunk was involved in the call (see below)
extensionlegsarrayDetails of extension legs — present when the call was connected to an extension (see below)
recordingsarrayRecording file details (see below)
statesarrayHistory of call states the call passed through (see below)
logsarrayFull 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.

FieldTypeDescription
callidstringThe Call-ID for this leg
directionstringDirection 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
fromstringValue of the From header at the time the call leg started. Note the value may change afterwards
tostringValue of the To header, like from but the other direction
final_to_dialedstringThe final SIP URI actually dialed on the trunk
remotepartystringRemote party of the call. For outbound calls, contains the number used for billing — often similar to to but may differ for group calls
localpartystringLocal party of the call
trunkstringID of the trunk used for the call
trunk-namestringDisplay name of the trunk
coststringCost for the call when available, defined in the rates table for the trunk
startfloatUnix epoch — leg started
connectfloatUnix epoch — leg answered. Not present if the call was not connected
endfloatUnix epoch — leg ended
codeintegerFinal SIP response code (e.g. 200, 487)
ipadrstringIP address perceived by the PBX for this call (transport:ip:port)
qualitystringVQ (Voice Quality) report calculated by the PBX for this leg (RFC 6035)
dtmfarrayDTMF digits collected during this leg, each with date (epoch) and dtmf (digit)
extensionstringIf this call was associated with an extension (e.g. cell phone call), the primary name of the extension
extension-namestringExtension display name
codecstringCodec used for this call
mosstring/integerMOS score as reported in the trunk's MOS score table. NR = not reported; numeric values are scaled × 10 (e.g. 42 = 4.2)
packetsstringFull 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.

FieldTypeDescription
callidstringThe Call-ID for this leg
fromstringValue of the From header at the time the call leg started
tostringValue of the To header
directionstringDirection 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
extensionstringPrimary account name of the extension
extension-namestringExtension display name
redirectstringValue of the To header as presented to the user's device. May differ from to due to caller-ID presentation settings in the group
idlestringSeconds 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)
startfloatUnix epoch — leg started
connectfloatUnix epoch — leg answered. Not present if the call was not connected (ringing only)
endfloatUnix epoch — leg ended
ipadrstringIP address perceived by the PBX for this call (transport:ip:port)
qualitystringVQ report calculated by the PBX for this leg (RFC 6035)
typestringCall type for this leg: m = missed, r = received (connected), d = dialled (outbound)
deletedstringtrue if this leg was a cancelled ring attempt (e.g. FCM push that was superseded)
uastringSIP User-Agent string of the endpoint
dtmfarrayDTMF digits collected during this leg
codecstringCodec used for this call
packetsstringFull SIP message trace for this leg

Recording Fields (recordings)

FieldTypeDescription
idintegerInternal recording ID
timefloatUnix epoch when recording started
filestringRelative path to the recording file on the PBX
logstringRecording log entry (if any)
accountstringAccount number (e.g. call queue) the recording is attributed to
account-namestringAccount display name
extensionstringExtension that handled the call
extension-namestringExtension 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.

FieldTypeDescription
typestringThe type of state (see table below)
fromstringValue of the From header when this state was entered
tostringValue of the To header
languagestringLanguage in use (two-letter code, e.g. en, au, de)
startfloatUnix epoch when this state was entered
durationivrstringTime in ms the call spent in an IVR state (playing announcements)
durationringstringTime in ms the call was ringing a user
durationtalkstringTime in ms the call was connected to a user. Independent from trunk connected time. Includes hold time
durationholdstringTime in ms the call was on hold in this state
durationidlestringTime in ms the connected extension was idle before this call
reasonstringReason the state was terminated (see table below)
accountstringPrimary name of the account used in this state
account-namestringAccount display name
extensionstringExtension involved in this state — the connected agent in a call queue or ring group
extension-namestringExtension display name

State Types

ValueDescription
acdCall queue — the call is waiting in or being handled by a queue
attendantAuto attendant, or a direct call to an extension (not through a group)
mailboxCall reached a mailbox
extcallUser placed a regular external call. May also include special operations like PIN authentication
huntRing group call
conferenceConference call
orbitCall is parked in a park orbit
hootPaging call
srvflagService flag was called (to change it)
ivrnodeIVR node processing
callerPBX-initiated call, e.g. inviting conference participants or hotel wake-up calls
starcodeStarcode processing, e.g. DND or call redirection

State Termination Reasons

ValueDescription
hcCall was connected and cleared normally
hwUser disconnected while waiting in the queue
hrUser disconnected while ringing in the queue
hbUser disconnected while the queue was sending a busy signal
rwState terminated due to wait timeout in the queue
rrState ended because the call rang too long in the queue
raCall redirected because it was anonymous
userUser pressed a DTMF key that triggered an action
soapExternal server response triggered a redirection or termination
missedCall 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.

FieldTypeDescription
tfloatUnix epoch timestamp of the event
cstringComponent: APP, SIP, MEDIA, TRUNK, BILL
lintegerLog level: 4=Error, 5=Warning, 6=Info, 7=Debug, 8=Verbose, 9=Trace
mstringLog message

Log Components

CodeComponent
APPApplication layer — call routing, call queue, state machine
SIPSIP signalling layer
MEDIAMedia/RTP layer — codecs, ICE, DTLS, SRTP, passthrough, transcoding
TRUNKTrunk and dial plan processing
BILLBilling 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

EventTimestampDetails
Call initiated1773802408.253776Extension 500 dials 132221
Call connected1773802411.431461~3s ring time
Call ended1773802413.945009~2.5s talk time

Structure

ArrayCountNotes
trunklegs1Outbound (O) via "Vodia Out" trunk
extensionlegs1Inbound (I) from extension 500
recordings0
states1acd 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

EventTimestampDetails
Call arrives at trunk1773803761.027467Inbound from +60433337285
Call queue connects1773803761.030508Queue 400 — agent 501 available
Agent 501 rings1773803774.708298Ring phase begins
Agent 501 answers1773803776.771537Talk begins
Call ends1773803786.776693~8s talk time

Structure

ArrayCountNotes
trunklegs1Inbound (I) from external number
extensionlegs2One answered, one cancelled FCM push attempt (deleted=true)
recordings1Recorded to call queue 400
states1acd 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

EventTimestampDetails
Call arrives at trunk1773804961.358170Inbound from +60433337285
Auto Attendant 100 answers1773804961.361828IVR announcements play
DTMF 4 pressed1773804967.154Attendant navigating menu
DTMF 4 pressed again1773804982.434Attendant navigating menu
DTMF 3 pressed1773804985.954Routes to call queue 400
Call queue 400 starts ringing agent1773804998.836993Agent 501 selected
Agent 501 answers1773805007.677447Talk begins, recording starts
Call ends1773805015.898469~6.5s talk time

Structure

ArrayCountNotes
trunklegs1Inbound (I) — DTMF digits captured in dtmf array
extensionlegs1Agent 501 (Android, answered)
recordings1Recorded to call queue 400
states2attendant (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:

MetricDescription
QualityEst:MOSLQListening quality MOS score (1.0–5.0, higher is better)
QualityEst:MOSCQConversational quality MOS score
PacketLoss:NLRNear-side packet loss ratio
Delay:RTDRound-trip delay (ms)
Delay:IAJInter-arrival jitter (ms)
x-SIPterm:SDRDisconnect 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

  1. Save the script below as webcdr_server.py
  2. Run it with gunicorn: gunicorn -w 1 -b 0.0.0.0:5001 webcdr_server:app
  3. Configure the PBX CDR URL as: webcdr http://your-server-ip:5001/cdr
warning

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.

note

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.