Data Migration and Import
When you move an organization onto Thirdlane from another PBX (for example 3CX, FreePBX, or a hosted provider), the configuration objects - extensions, queues, IVRs, routes - are created with the standard REST API. But historical assets and media need a different set of endpoints: recorded calls, voicemail messages and greetings, Music on Hold audio, voice prompts, and Call Detail Records (CDR).
This page documents those import and upload endpoints. They are source-neutral: nothing about them is specific to 3CX. A migration tool (or your own script) reads data from the old system, converts it to the neutral shapes described here, and posts it in. The same endpoints are equally useful for one-off uploads outside a migration - dropping a new hold track into a playlist, or loading a batch of CDR from an external system.
All endpoints are tenant-scoped and live under /api/tenants/{tenant}/... (the voice-prompt upload is the one exception - it uses the existing /api/recordings/{tenant} endpoint). See the Scalar UI under Tools > OpenAPI REST (or /apitest/openapi/) for full request and response schemas; the Migration Import tag groups them together.
How the import endpoints work
A few conventions apply across all of these endpoints. Understanding them up front avoids the common migration pitfalls.
Authentication
Use an API key scoped to the target tenant, passed in the X-API-Key header. A tenant-scoped key cannot touch other tenants and cannot run global operations, which is exactly what you want for a migration script.
export TL_HOST=https://pbx.example.comexport TL_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxexport TL_TENANT=acmeAudio format
You can upload audio as either:
- Base64 in JSON - put the bytes in an
audio_base64field. This is the simplest path for most clients. - Multipart form-data - send the file as a part named
audio(orfilefor CDR CSV).
The server accepts common WAV and MP3 input and converts everything to the Asterisk-native format (8 kHz mono PCM WAV). You do not need to transcode on your side. Call recordings are stored with the native .WAV naming the platform uses for live recordings, so playback works unchanged.
Timestamps and time zones
Every timestamp is supplied as an absolute value - either epoch seconds or ISO-8601 with an explicit offset or Z:
17461081802026-05-01T14:03:00Z2026-05-01T09:03:00-05:00The server converts to the server’s local wall-clock time for storage and display, using DST-correct rules. Do not pre-convert times to the server’s local zone on your side - send the true instant and let the server localize it. This keeps CDR dates, recording dates, and recording file-name stamps consistent with one another.
Idempotency (safe re-runs)
Migrations often run more than once (a test pass, then the real cutover, then a “catch the last few days” pass). To make that safe, supply a stable external_id from the source system on each recording and voicemail message. The server dedups on (tenant, external_id) and skips anything already imported. For CDR, the uniqueid column serves the same purpose - rows already present for the tenant are skipped.
Because of this, you can re-run any import and only new records are created; the response envelope reports created, skipped, and failed counts.
Reloads and batching
Some imports normally trigger a background reload so the change takes effect immediately (a new MoH file, a synced prompt, a refreshed message-waiting light). During a bulk migration you do not want a reload after every single call. Every endpoint that can reload accepts a reload flag that defaults to true:
- For one-off calls, omit
reload- the change applies right away. - For bulk runs, send
reload=falseon each call, then call the matching finalize endpoint once at the end:POST /api/tenants/{tenant}/musiconhold/reload- after uploading MoH filesPOST /api/tenants/{tenant}/sounds/sync- after uploading voice promptsPOST /api/tenants/{tenant}/voicemail/mwi-refresh- after importing voicemail messages
Import order
Two ordering rules matter:
- Import recordings before CDR. A CDR row carries the path to its recording; import the audio first so the link resolves when you load the CDR.
- Create containers before contents. Create a Music on Hold class before uploading files into it, and make sure a mailbox exists before importing greetings or messages for it.
Endpoint reference
| Area | Method and path | Purpose |
|---|---|---|
| Music on Hold | POST /api/tenants/{tenant}/musiconhold | Create or reuse a MoH class |
POST /api/tenants/{tenant}/musiconhold/{class}/files | Upload one audio file into a class | |
POST /api/tenants/{tenant}/musiconhold/reload | Finalize: reload MoH | |
| Voice prompts (OGM) | POST /api/recordings/{tenant} | Upload a prompt (or generate from text) |
POST /api/tenants/{tenant}/sounds/sync | Finalize: sync prompts | |
| Voicemail greetings | POST /api/tenants/{tenant}/extensions/{ext}/greetings | Upload a greeting or recorded name |
| Voicemail messages | POST /api/tenants/{tenant}/voicemail/import | Import one or many messages |
POST /api/tenants/{tenant}/voicemail/mwi-refresh | Finalize: refresh message-waiting lights | |
| Call recordings | POST /api/tenants/{tenant}/recordedcalls | Import one recording with metadata |
| CDR | POST /api/tenants/{tenant}/cdr/import | Start an async bulk CDR import |
GET /api/tenants/{tenant}/cdr/import | List recent import jobs | |
GET /api/tenants/{tenant}/cdr/import/{job} | Poll one job’s status |
Music on Hold
Create the class first, then add files. The class name is stored on disk under its bare name; internally the platform suffixes it with the tenant.
curl "$TL_HOST/api/tenants/$TL_TENANT/musiconhold" \ -X POST -H "X-API-Key: $TL_KEY" -H "Content-Type: application/json" \ --data-binary '{ "class": "default", "format": "wav" }'Upload files with reload=false during a batch, then reload once:
curl "$TL_HOST/api/tenants/$TL_TENANT/musiconhold/default/files" \ -X POST -H "X-API-Key: $TL_KEY" -H "Content-Type: application/json" \ --data-binary '{ "filename": "track1.wav", "audio_base64": "UklGR... ", "reload": false }'
curl "$TL_HOST/api/tenants/$TL_TENANT/musiconhold/reload" \ -X POST -H "X-API-Key: $TL_KEY"Pass set_default: true when creating the class to make it the tenant’s default hold music.
Voice prompts (OGM)
The existing Greetings endpoint (POST /api/recordings/{tenant}) now accepts an audio upload in addition to text-to-speech. Supply name plus audio_base64 (or a multipart audio part) to store an existing prompt; supply name plus text to synthesize one.
curl "$TL_HOST/api/recordings/$TL_TENANT" \ -X POST -H "X-API-Key: $TL_KEY" -H "Content-Type: application/json" \ --data-binary '{ "name": "main-greeting", "audio_base64": "UklGR... ", "reload": false }'
curl "$TL_HOST/api/tenants/$TL_TENANT/sounds/sync" \ -X POST -H "X-API-Key: $TL_KEY"The prompt name (no extension) is what IVRs, queues, and hunt groups reference, so keep names stable across the migration.
Voicemail greetings
Each mailbox has four slots: unavail, busy, temp, and greet - the last being the spoken name that the company directory plays, not an extra greeting. (name is still accepted as an alias for greet.) The mailbox must already exist.
curl "$TL_HOST/api/tenants/$TL_TENANT/extensions/1001/greetings" \ -X POST -H "X-API-Key: $TL_KEY" -H "Content-Type: application/json" \ --data-binary '{ "type": "unavail", "audio_base64": "UklGR... " }'An imported greeting is added to the mailbox’s greeting library and then activated, so it behaves exactly like one the user recorded themselves - visible in Connect and the portal, and switchable without re-uploading. Pass name to control the library entry it creates; re-importing with the same name replaces that entry instead of adding a duplicate.
Voicemail messages
Import one message via top-level fields, or many via a messages array. Each message carries its audio, caller ID, time, duration, and heard status. Messages marked heard land in the Old folder; unheard messages land in INBOX (new) and light the message-waiting indicator. Supply external_id for dedup.
curl "$TL_HOST/api/tenants/$TL_TENANT/voicemail/import" \ -X POST -H "X-API-Key: $TL_KEY" -H "Content-Type: application/json" \ --data-binary '{ "mailbox": "1001", "reload": false, "messages": [ { "audio_base64": "UklGR...", "callerid": "\"John Doe\" <2025551234>", "when": "2026-05-01T14:03:00Z", "duration": 23, "heard": false, "external_id": "src-vm-88213" } ] }'
curl "$TL_HOST/api/tenants/$TL_TENANT/voicemail/mwi-refresh" \ -X POST -H "X-API-Key: $TL_KEY" -H "Content-Type: application/json" \ --data-binary '{ "mailboxes": ["1001"] }'Omit the mailboxes array on mwi-refresh to refresh every mailbox in the tenant.
Call recordings
Import each recording with its metadata. when and dst are required; supply src, type (extension or queue), queue, duration, clid, did, and external_id as available. Recordings are placed under the monitor directory using the platform’s native naming so the Recorded Calls screen and playback work unchanged.
curl "$TL_HOST/api/tenants/$TL_TENANT/recordedcalls" \ -X POST -H "X-API-Key: $TL_KEY" -H "Content-Type: application/json" \ --data-binary '{ "audio_base64": "UklGR...", "when": "2026-05-01T14:03:00Z", "type": "extension", "src": "2025551234", "dst": "1001", "duration": 42, "external_id": "src-rec-55231" }'Import recordings before the CDR that references them.
CDR (bulk, asynchronous)
CDR volumes can be very large, so this import is a background job. Upload a CSV (recommended) or an inline records array; the server stages it, dedups against existing rows, and loads the new ones. The call returns a job_id you poll for progress.
The CSV header must include a uniqueid column and one of calldate_epoch (preferred) or calldate. The tenant is applied automatically - do not put a tenant in userfield.
# Preview first with dry_run - counts what would be created, writes nothingcurl "$TL_HOST/api/tenants/$TL_TENANT/cdr/import" \ -X POST -H "X-API-Key: $TL_KEY" \ -F "file=@cdr.csv" -F "dry_run=1"
# Real importJOB=$(curl -s "$TL_HOST/api/tenants/$TL_TENANT/cdr/import" \ -X POST -H "X-API-Key: $TL_KEY" -F "file=@cdr.csv" \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["job_id"])')
# Poll until status is done or failedcurl "$TL_HOST/api/tenants/$TL_TENANT/cdr/import/$JOB" \ -X GET -H "X-API-Key: $TL_KEY"The status response reports status (pending, running, done, failed), total, created, skipped, failed, and any errors. GET /api/tenants/{tenant}/cdr/import lists recent jobs.
Worked example: migrate historical data from another PBX
You are cutting a customer, Acme, over from an old PBX. Configuration (extensions, queues, IVRs) is already created on Thirdlane. Now you want their hold music, prompts, voicemail, recordings, and call history to come across, and you want the whole thing to be safe to re-run.
1. Set up a scoped key. In Tools > API Keys, create a key bound to the acme tenant and export it:
export TL_HOST=https://pbx.example.comexport TL_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxexport TL_TENANT=acme2. Hold music. Create the class, upload each track with reload=false, then reload once:
curl "$TL_HOST/api/tenants/$TL_TENANT/musiconhold" -X POST -H "X-API-Key: $TL_KEY" \ -H "Content-Type: application/json" --data-binary '{ "class": "default" }'
# for each track: POST .../musiconhold/default/files with "reload": falsecurl "$TL_HOST/api/tenants/$TL_TENANT/musiconhold/reload" -X POST -H "X-API-Key: $TL_KEY"3. Voice prompts. Upload each prompt to /api/recordings/$TL_TENANT with reload=false, keeping the original names so the already-migrated IVRs still resolve them, then sync once:
curl "$TL_HOST/api/tenants/$TL_TENANT/sounds/sync" -X POST -H "X-API-Key: $TL_KEY"4. Voicemail. For each mailbox, upload greetings, then import messages with reload=false (preserving each message’s original time and heard/new status and an external_id). After all mailboxes are done, refresh lights once:
curl "$TL_HOST/api/tenants/$TL_TENANT/voicemail/mwi-refresh" -X POST -H "X-API-Key: $TL_KEY"5. Recordings. Import every recording with its when, src, dst, and a stable external_id. Do this before step 6 so CDR rows can link to the audio.
6. CDR. Export the old call history to a CSV with a uniqueid column and a calldate_epoch column. Dry-run it first to confirm the count, then run it for real and poll the job to completion:
curl "$TL_HOST/api/tenants/$TL_TENANT/cdr/import" -X POST -H "X-API-Key: $TL_KEY" \ -F "file=@acme-cdr.csv" -F "dry_run=1"7. Re-run safely. Between the test cutover and the final one, new calls and voicemails accumulate on the old system. Just run the same scripts again: recordings and voicemails dedup on external_id, CDR dedups on uniqueid, so only the newly added records are imported and everything else is skipped.
Best practices
- Use a tenant-scoped API key. A migration script should not carry platform-admin credentials.
- Send absolute timestamps (epoch or ISO-8601 with offset). Let the server localize - do not pre-convert to server time.
- Always set a stable
external_id(and a realuniqueidfor CDR) so re-runs are idempotent. - Batch with
reload=falseand call the finalize endpoints once at the end, rather than reloading after every call. - Import recordings before CDR, and create MoH classes and mailboxes before loading their contents.
- Dry-run large CDR imports first and check the
created/skipped/failedcounts before committing. - Mind retention. Imported history is subject to the same Keep CDR/Recorded calls for settings on the Tenant; make sure retention is long enough to keep what you import.
Related documentation
- REST API - the configuration API used to create extensions, queues, and routes
- OpenAPI REST - interactive Scalar reference and curl examples
- API Keys - create and scope authentication keys
- Greetings - voice prompts used by menus and routes
- Music-on-Hold - hold playlists
- Recorded Calls - review imported recordings
- CDR - view imported call detail records