CallDetailRecord Model
The CallDetailRecord model stores the final record of every call, created from the Cloudonix CDR webhook. Records age through three storage tiers.
Table: call_detail_records (hot), archived_call_detail_records (warm)
Models: App\Models\CallDetailRecord, App\Models\ArchivedCallDetailRecord
Database Schema
| Column | Type | Nullable | Description |
|---|---|---|---|
id | bigint unsigned | No | Primary key (kept when a row moves to the warm table) |
organization_id | bigint unsigned | No | Tenant scope |
session_timestamp | timestamp | No | Session time. Tiering is based on this, in UTC |
session_token | varchar(100) | Yes | Cloudonix session token |
from / to | varchar(100) | No | Caller ID (display only) and called number (destination) |
extension_number | varchar(20) | Yes | The organization's extension the call belongs to, set at ingest by extensionNumberFor(). Null when no rule applies |
direction | varchar(20) | Yes | incoming, outgoing, internal or application. Copied from the latest session update when the CDR is ingested; null for older rows |
disposition | varchar(50) | No | Final disposition (for example ANSWER, BUSY) |
duration / billsec | int | No | Total and billed seconds. Default 0 |
call_id | varchar(255) | No | Cloudonix call ID |
domain | varchar(255) | Yes | Cloudonix domain |
subscriber | varchar(100) | Yes | Subscriber (extension) UUID |
cx_trunk_id | int | Yes | Trunk |
application, route | varchar(255) | Yes | Cloudonix routing details |
vapp_server | varchar(50) | Yes | Cloudonix voice application server |
rated_cost, approx_cost, sell_cost | decimal(10,4) | Yes | Costs |
session_id | bigint | Yes | Cloudonix session ID |
call_start_time, call_answer_time, call_end_time | timestamp | Yes | Call times |
status | varchar(50) | Yes | Status |
recording_status | enum | No | none (default), pending, available, failed |
recording_source_url, recording_stored_path, recording_mime_type | varchar(255) | Yes | Recording location |
recording_duration | int | Yes | Recording length |
amd_result | varchar(20) | Yes | AMD result for auto-dialer calls |
amd_confidence | decimal(5,2) | Yes | AMD confidence |
raw_cdr | json | No | The CDR payload as received |
is_auto_dialer | boolean | No | Auto-dialer call. Default false |
auto_dialer_campaign_id | bigint unsigned | Yes | Campaign. Foreign key to auto_dialer_campaigns, ON DELETE SET NULL |
created_at / updated_at | timestamp | Yes |
archived_call_detail_records has the same columns and keeps the original IDs.
organization_id references organizations with ON DELETE CASCADE. amd_result, amd_confidence, is_auto_dialer and auto_dialer_campaign_id are not mass assignable; they are set separately for auto-dialer calls.
Indexes
Single-column indexes on call_id, disposition, domain, from, to, session_timestamp, session_token and auto_dialer_campaign_id, plus these composite indexes:
| Index | Columns |
|---|---|
cdr_org_ts_idx | organization_id, session_timestamp |
cdr_org_disp_idx | organization_id, disposition |
cdr_org_from_idx | organization_id, from |
cdr_org_to_idx | organization_id, to |
cdr_org_ext_idx | organization_id, extension_number (warm table: acdr_org_ext_idx) |
cdr_org_session_idx | organization_id, session_id |
cdr_auto_dialer_org_idx | is_auto_dialer, organization_id |
Storage Tiers
| Tier | Where | Age (default) |
|---|---|---|
| Hot | call_detail_records in the main database | Up to CDR_HOT_MONTHS (6) |
| Warm | archived_call_detail_records on the cdr_archive connection. That is the main schema unless DB_CDR_ARCHIVE_DATABASE (and the optional DB_CDR_ARCHIVE_HOST, _PORT, _USERNAME, _PASSWORD) points elsewhere | Up to CDR_WARM_MONTHS (12) |
| Cold | Compressed CSV files in the CDR_ARCHIVE_BUCKET bucket (default cdr-archive) | Kept CDR_COLD_RETENTION_YEARS (7) |
cdr:tier moves rows between tiers daily at 04:00 UTC, and recordings:expire deletes expired recordings at 04:30 UTC (RECORDING_RETENTION_MONTHS, at most the cold retention). Call records are never pruned by model:prune. ArchivedCallDetailRecord is read-only and is written only by cdr:tier. See Call Logs.
Relationships
$cdr->organization() → Organization
$cdr->extension() → Extension (by subscriber = cloudonix_uuid)
$cdr->sessionUpdate() → SessionUpdate (latest for the session)
Methods
CallDetailRecord::createFromWebhook(array $payload, int $organizationId): self
CallDetailRecord::extensionNumberFor(int $organizationId, ?string $subscriber, string $from, string $to): ?string
CallDetailRecord::forOrganization($orgId)
CallDetailRecord::withDisposition($disposition)
CallDetailRecord::connected()
CallDetailRecord::betweenDates($start, $end)
$cdr->formatted_duration / $cdr->formatted_billsec / $cdr->amd_status
createFromWebhook() takes a cache lock on cdr:create:{organization_id}:{call_id} (held up to 10 seconds, waits up to 5) and returns the existing row for that call_id if there is one, so a redelivered CDR does not create a second row. extensionNumberFor() returns, within the organization, the extension whose cloudonix_uuid is the CDR's subscriber (the subscriber that placed the call), else the extension whose number is the caller ID without + (API-initiated calls), else the extension that the DID in to (with or without +) routes to directly (routing_type extension), else the answering extension OPBX recorded for the CDR's session_token; otherwise null. recordAnsweringExtension($organizationId, $sessionToken, $extensionNumber) stores that answering extension in Redis (cdr:answerer:{organization_id}:{session_token}, SET NX with a 24-hour TTL, so the first one recorded wins) and sets extension_number on an already stored CDR of that session whose extension_number is still null. It is called by UserRoutingStrategy (any single-extension dial: DID, IVR option, business hours, fallbacks), by the ring group callback when a Sequential or Round Robin member's dial is answered (DialCallStatus/CallStatus answered, completed or in-progress; the member travels in the callback URL as dialed_extension), by RingGroupConferenceService::claim when a member of a Simultaneous ring group answers its own call (the member's extension number, keyed by the caller's session token), and by queue finalization (QueueCallLifecycleService::finalizeFromCdr, the agent's extension for an answered call). Ring All queue dials with separate calls switched off (one multi-number <Dial>) record nothing: Cloudonix does not report the winning leg. The migration 2026_10_14_220000_add_extension_number_to_call_detail_records added the column on both tiers and filled it for existing rows with the same rule (idempotent: only rows still null are touched). The migration 2026_10_15_090000_backfill_cdr_extension_number_from_queue_agents fills, on both tiers, CDRs still null whose session_token matches an answered queue_calls row (by session_token or call_id) with the extension of its agent_user_id (idempotent). extension() matches subscriber against extensions.cloudonix_uuid; sessionUpdate() is the latest session_updates row with the same session_id.