Send a voice message

Ring a list of phones and play a recording. One call, with the audio in the request — there is no separate upload step unless you want one.

POST/api/v1/voice/send
Migrating an existing integration? These field names — voice_file, voice_id, retry, recipients, callback_url — follow the conventions most Ghanaian voice APIs use, so moving across is usually a new base URL and a new key and nothing more. Two things to check: voice_file is base64 rather than multipart, and we answer 202 rather than 200.

Supplying the audio

Exactly one of these three. More than one is rejected rather than us guessing which you meant.

Audio (pick one)
voice_file
string (base64)
The audio file itself. MP3, WAV, FLAC, AAC, M4A or OGG; up to 10 MB and 5 minutes. Pair with voice_file_type when it is not MP3.
audio_file_id
string
A recording you uploaded earlier via POST /api/v1/voice/audio. Use this when you send the same message repeatedly rather than pushing the bytes every time.
message
string
Text for us to speak, up to 500 characters. Numbers are read digit by digit.
Parameters
recipients
string[]required
Up to 10,000 numbers, as 024… or +233…. Duplicates are removed — nobody is called twice.
voice_id
string
Caller ID shown to the recipient, max 10 characters. Falls back to a shared number when unset.
retry
boolean | string
Re-dial anyone who does not answer. Defaults to true. Accepts a real boolean or the string "false", so either convention works. Each attempt is charged again.
callback_url
string
Where to POST each call's outcome. Optional — your organisation's signed webhook fires regardless.
voice_file_name
string
Original filename. Only used to pick the stored extension.
voice_file_type
string
MIME type, e.g. "audio/wav". Defaults to "audio/mpeg".
campaign_name
string
A label for your own reporting.
scheduled_for
string (ISO 8601)
Dial later. Call time is reserved now and released if you cancel before it dials.
Shell
curl -X POST https://api.jkasms.com/api/v1/voice/send \
  -H "Authorization: Bearer $JKASMS_SECRET_KEY" \
  -H "X-JKASMS-Org-Id: $JKASMS_ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "voice_file": "'"$(base64 -w0 announcement.mp3)"'",
    "voice_file_name": "announcement.mp3",
    "voice_id": "0540000000",
    "retry": "false",
    "recipients": ["0241234567", "0551234567"],
    "callback_url": "https://example.com/voice-callback"
  }'
Response202
JSON
{
  "success": true,
  "message": "Voice SMS request sent successfully!",
  "data": {
    "campaign_id": "cmujohinz0003pz707b91rmmb",
    "recipients": [
      { "call_id": "cmujohip70008pz70wdr93wri", "recipient": "+233241234567", "status": "QUEUED" },
      { "call_id": "cmujohip70009pz70a2k1xm4c", "recipient": "+233551234567", "status": "QUEUED" }
    ],
    "rejected": [],
    "duration_seconds": 18,
    "credits_used": 36,
    "credits_label": "36s"
  }
}

202, not 200. The calls are queued, not placed. Saying “OK” for work that has not happened yet is how a developer concludes a call connected when it is still in a worker queue.

What it costs

credits_used is the recording's length times the number of recipients, in seconds, taken from your voice credit:

18 seconds × 2 recipients = 36 seconds

The length is measured from your file on upload, never taken from the request — a caller who could declare their own duration would be writing their own invoice.

With no voice credit the request is refused with 402 INSUFFICIENT_BALANCE, even if you hold thousands of SMS credits. The two balances cannot pay for each other. Price a send first with POST /api/v1/voice/quote, which takes the same body and dials nothing.

Outcomes

Call time is reserved on send and settled per call: an answered call is charged, and a call that rings out, is busy or fails releases its seconds back to you. Outcomes arrive as webhooks — voice.call.answered, voice.call.no_answer, voice.call.busy, voice.call.failed — signed the same way as SMS delivery events. See Verifying signatures.

JSON
{
  "event": "voice.call.answered",
  "data": {
    "call_id": "cmujohip70008pz70wdr93wri",
    "recipient": "+233241234567",
    "status": "ANSWERED",
    "credits_used": 18,
    "actual_duration_seconds": 18,
    "retries": 0,
    "campaign_id": "cmujohinz0003pz707b91rmmb",
    "campaign_name": "Closure notice",
    "failure_reason": null,
    "submitted_at": "2026-09-27T09:31:02.000Z",
    "answered_at": "2026-09-27T09:31:19.000Z",
    "failed_at": null
  },
  "sent_at": "2026-09-27T09:31:19.412Z"
}

If you passed callback_url, that URL receives the same payload. Set both and you are called once per destination, not twice for the same one.

The rest of the endpoints

POST/api/v1/voice/quote

Prices a send. Same body, nothing dialled, nothing charged.

POST/api/v1/voice/audio

Uploads a recording and returns an audio_file_id plus its measured duration_seconds.

GET/api/v1/voice/calls/{id}
GET/api/v1/voice/calls

One call, or a cursor-paginated list. Filter by campaign_id, status, recipient, from and to (both YYYY-MM-DD, inclusive).

GET/api/v1/voice/campaigns/{id}

Progress and cost: answered, failed, pending, credits_reserved against credits_used.

POST/api/v1/voice/campaigns/{id}/cancel

Stops calls that have not dialled and returns their seconds. Calls already handed to the carrier are on the network and cannot be recalled; the response says how many.

GET/api/v1/voice/balance

Call time remaining, in seconds.

Test keys

A TEST key routes only to a carrier with a sandbox, so it never rings a real phone. If no sandbox voice route is configured the campaign is accepted and marked as sandbox, and nothing is dialled. See Test mode.