Skip to content

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.

Terminal window
export TL_HOST=https://pbx.example.com
export TL_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
export TL_TENANT=acme

Audio format

You can upload audio as either:

  • Base64 in JSON - put the bytes in an audio_base64 field. This is the simplest path for most clients.
  • Multipart form-data - send the file as a part named audio (or file for 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:

1746108180
2026-05-01T14:03:00Z
2026-05-01T09:03:00-05:00

The 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=false on each call, then call the matching finalize endpoint once at the end:
    • POST /api/tenants/{tenant}/musiconhold/reload - after uploading MoH files
    • POST /api/tenants/{tenant}/sounds/sync - after uploading voice prompts
    • POST /api/tenants/{tenant}/voicemail/mwi-refresh - after importing voicemail messages

Import order

Two ordering rules matter:

  1. 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.
  2. 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

AreaMethod and pathPurpose
Music on HoldPOST /api/tenants/{tenant}/musiconholdCreate or reuse a MoH class
POST /api/tenants/{tenant}/musiconhold/{class}/filesUpload one audio file into a class
POST /api/tenants/{tenant}/musiconhold/reloadFinalize: reload MoH
Voice prompts (OGM)POST /api/recordings/{tenant}Upload a prompt (or generate from text)
POST /api/tenants/{tenant}/sounds/syncFinalize: sync prompts
Voicemail greetingsPOST /api/tenants/{tenant}/extensions/{ext}/greetingsUpload a greeting or recorded name
Voicemail messagesPOST /api/tenants/{tenant}/voicemail/importImport one or many messages
POST /api/tenants/{tenant}/voicemail/mwi-refreshFinalize: refresh message-waiting lights
Call recordingsPOST /api/tenants/{tenant}/recordedcallsImport one recording with metadata
CDRPOST /api/tenants/{tenant}/cdr/importStart an async bulk CDR import
GET /api/tenants/{tenant}/cdr/importList 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.

Terminal window
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:

Terminal window
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.

Terminal window
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.

Terminal window
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.

Terminal window
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.

Terminal window
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.

Terminal window
# Preview first with dry_run - counts what would be created, writes nothing
curl "$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 import
JOB=$(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 failed
curl "$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:

Terminal window
export TL_HOST=https://pbx.example.com
export TL_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
export TL_TENANT=acme

2. Hold music. Create the class, upload each track with reload=false, then reload once:

Terminal window
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": false
curl "$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:

Terminal window
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:

Terminal window
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:

Terminal window
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 real uniqueid for CDR) so re-runs are idempotent.
  • Batch with reload=false and 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/failed counts 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.
  • 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