๐Ÿ’ฐ Earn โ‚ฆ0.50 on every answered call of people you refer โ€” join our affiliate programme
VoiceSMS

Developers ยท API v1

Voice SMS API

Send recorded voice messages to Nigerian mobile numbers from your own website, app or CRM, and get each call's result back automatically.

Overview

The API is a JSON REST API. All requests go to this base URL over HTTPS:

https://voicesms.infotekps.com/api/v1

A typical integration:

  1. Create an API key in your dashboard under API & Webhooks.
  2. Upload your recording once (or upload it in the dashboard). Our team approves it, usually within a few hours.
  3. Send it to your numbers with POST /campaigns.
  4. Receive each call's result on your webhook, or read it with GET /campaigns/{id}/calls.

Authentication

Every request needs your API key in the Authorization header. Keys start with vsk_ and are shown once when you create them. You can create up to 10 keys (one per system is a good habit) and revoke any of them instantly.

Authorization: Bearer vsk_your_api_key
Accept: application/json

A key can send campaigns and spend your credits. Keep it on your server only โ€” never in a website, mobile app or public code repository.

Requests & errors

  • Send JSON bodies with Content-Type: application/json (file uploads use multipart/form-data).
  • Successful responses wrap the result in data. Lists are paginated: use ?page= and ?per_page=, and follow links.next until it is null.
  • Times are ISO 8601 in Lagos time (WAT, +01:00). Phone numbers are returned as +234โ€ฆ.
HTTP statusTypeDescription
200 / 201 OK Success. 201 means something was created.
401 Unauthorized Missing or invalid API key.
403 Forbidden Account suspended or not activated, or the endpoint is for resellers only.
404 Not found The item does not exist or does not belong to your account.
409 Conflict The action is not possible now, e.g. cancelling a finished campaign.
422 Validation error Something in the request is wrong. The errors object says what, per field (e.g. not enough credits, sending paused).
429 Too many requests Rate limit reached. Wait for the number of seconds in the Retry-After header.
{
    "message": "You need 400 credits for this campaign but have 120. Please top up.",
    "errors": {
        "numbers": [
            "You need 400 credits for this campaign but have 120. Please top up."
        ]
    }
}

Rate limits per API key: 120 requests a minute in total, of which at most 30 send requests and 10 uploads. One send request can contain up to 100,000 numbers, so batch your numbers instead of sending one request per number.

How billing works

  • You pay in prepaid credits. One credit covers up to 15 seconds of an answered call; a 30-second message uses 2 credits per answered call.
  • When you send, credits for every number are reserved (credits_reserved). Each answered call is charged for its actual length, up to the reserved amount.
  • Unanswered, busy and failed calls are free. Unused credits are refunded automatically when the campaign finishes (credits_refunded).
  • Calls are placed between 08:00 โ€“ 20:00 (Lagos time). Campaigns sent outside these hours wait and start when the window opens.

Account

GET /api/v1/account

Your account and credit balance.

curl https://voicesms.infotekps.com/api/v1/account \
  -H "Authorization: Bearer vsk_your_api_key" \
  -H "Accept: application/json"
$ch = curl_init('https://voicesms.infotekps.com/api/v1/account');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ['Authorization: Bearer vsk_your_api_key', 'Accept: application/json'],
    CURLOPT_RETURNTRANSFER => true,
]);
$account = json_decode(curl_exec($ch), true)['data'];
echo $account['credits'];
import requests

r = requests.get("https://voicesms.infotekps.com/api/v1/account",
                 headers={"Authorization": "Bearer vsk_your_api_key", "Accept": "application/json"})
print(r.json()["data"]["credits"])
{
    "data": {
        "id": 42,
        "name": "Ada Okafor",
        "email": "ada@example.com",
        "type": "user",
        "credits": 1880,
        "credit_value_ngn": 14100,
        "price_per_credit_ngn": 7.5,
        "seconds_per_credit": 15
    }
}

Voice messages

A voice message is the recording people hear when they answer. It must be approved by our team before it can be sent (status: pending โ†’ approved or rejected with a rejection_reason).

POST /api/v1/voice-files

Upload a recording as multipart/form-data. Any common format is accepted and converted to MP3; maximum 60 seconds and 10MB.

ParametersTypeDescription
title required string A name for the recording (max 100 characters).
audio required file MP3, WAV, M4A, OGG, AAC, AMR or WMA.
curl https://voicesms.infotekps.com/api/v1/voice-files \
  -H "Authorization: Bearer vsk_your_api_key" \
  -H "Accept: application/json" \
  -F "title=October promo" \
  -F "audio=@promo.mp3"
$ch = curl_init('https://voicesms.infotekps.com/api/v1/voice-files');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => ['Authorization: Bearer vsk_your_api_key', 'Accept: application/json'],
    CURLOPT_POSTFIELDS => ['title' => 'October promo', 'audio' => new CURLFile('promo.mp3')],
    CURLOPT_RETURNTRANSFER => true,
]);
$voiceFile = json_decode(curl_exec($ch), true)['data']; // status is "pending" until approved
import requests

with open("promo.mp3", "rb") as audio:
    r = requests.post("https://voicesms.infotekps.com/api/v1/voice-files",
                      headers={"Authorization": "Bearer vsk_your_api_key", "Accept": "application/json"},
                      data={"title": "October promo"}, files={"audio": audio})
print(r.json()["data"]["status"])  # "pending" until approved
{
    "data": {
        "id": 12,
        "title": "October promo",
        "status": "pending",
        "rejection_reason": null,
        "duration_seconds": 28,
        "credits_per_call": 2,
        "created_at": "2026-10-08T09:02:11+01:00"
    }
}

GET /api/v1/voice-files

List your recordings, newest first. Filter with ?status=approved.

GET /api/v1/voice-files/{id}

One recording โ€” use it to check whether it has been approved.

Send voice SMS

POST /api/v1/campaigns

Send an approved recording to one or many numbers. Credits are reserved immediately; if you do not have enough, nothing is sent and you get a 422.

ParametersTypeDescription
voice_file_id required integer ID of an approved voice message.
numbers required array of strings Nigerian mobile numbers, 1 to 100,000. Accepted formats: 08031234567, 2348031234567, +2348031234567. Invalid numbers are skipped and duplicates removed.
name string Your name for this send, shown in reports.
scheduled_at datetime Send later, e.g. 2026-10-09T09:00:00+01:00. Omit to send now.
reference string Your own unique ID for this send (max 100). If you retry a request with the same reference, the original campaign is returned and nothing is sent twice.
curl https://voicesms.infotekps.com/api/v1/campaigns \
  -H "Authorization: Bearer vsk_your_api_key" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "voice_file_id": 12,
    "numbers": ["08031234567", "+2348051112222"],
    "name": "October promo",
    "reference": "order-10045"
  }'
$ch = curl_init('https://voicesms.infotekps.com/api/v1/campaigns');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer vsk_your_api_key',
        'Accept: application/json',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'voice_file_id' => 12,
        'numbers' => ['08031234567', '+2348051112222'],
        'name' => 'October promo',
        'reference' => 'order-10045', // retrying with the same reference never sends twice
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$campaign = json_decode(curl_exec($ch), true)['data'];
echo $campaign['id'], ' ', $campaign['status'];
import requests

r = requests.post("https://voicesms.infotekps.com/api/v1/campaigns",
                  headers={"Authorization": "Bearer vsk_your_api_key", "Accept": "application/json"},
                  json={
                      "voice_file_id": 12,
                      "numbers": ["08031234567", "+2348051112222"],
                      "name": "October promo",
                      "reference": "order-10045",  # retrying with the same reference never sends twice
                  })
campaign = r.json()["data"]
print(campaign["id"], campaign["status"])
{
    "data": {
        "id": 345,
        "reference": "order-10045",
        "name": "October promo",
        "status": "queued",
        "status_note": null,
        "voice_file_id": 12,
        "recipients": 2,
        "answered": 0,
        "failed": 0,
        "pending": 2,
        "credits_per_call": 2,
        "credits_reserved": 4,
        "credits_charged": 0,
        "credits_refunded": 0,
        "scheduled_at": null,
        "started_at": null,
        "completed_at": null,
        "created_at": "2026-10-08T10:15:00+01:00"
    },
    "meta": {
        "invalid_numbers": [
            "0803123"
        ],
        "duplicates_removed": 0
    }
}

A retried request with an existing reference returns 200 with the original campaign.

Campaigns & results

GET /api/v1/campaigns

Your campaigns, newest first. Filter with ?status=processing.

GET /api/v1/campaigns/{id}

Progress and credit totals for one campaign.

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

Per-number delivery results, in the order the numbers were added. Filter with ?status=answered; up to 1,000 per page with ?per_page=.

curl "https://voicesms.infotekps.com/api/v1/campaigns/345/calls?status=answered&per_page=500" \
  -H "Authorization: Bearer vsk_your_api_key" \
  -H "Accept: application/json"
import requests

url = "https://voicesms.infotekps.com/api/v1/campaigns/345/calls?per_page=1000"
headers = {"Authorization": "Bearer vsk_your_api_key", "Accept": "application/json"}
while url:
    page = requests.get(url, headers=headers).json()
    for call in page["data"]:
        print(call["phone"], call["status"], call["duration_seconds"])
    url = page["links"]["next"]  # None on the last page
{
    "data": [
        {
            "id": 9812,
            "campaign_id": 345,
            "phone": "+2348031234567",
            "network": "MTN",
            "status": "answered",
            "duration_seconds": 27,
            "credits_charged": 2,
            "reason": "NORMAL_CLEARING",
            "dialed_at": "2026-10-08T10:15:04+01:00",
            "ended_at": "2026-10-08T10:15:41+01:00"
        }
    ],
    "links": {
        "next": "https://voicesms.infotekps.com/api/v1/campaigns/345/calls?page=2"
    },
    "meta": {
        "current_page": 1,
        "per_page": 100,
        "total": 2
    }
}

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

Stop a campaign. Calls already ringing finish normally; numbers not yet called are skipped and their credits refunded. Returns the updated campaign, or 409 if it had already finished.

Webhooks

Set a public https:// URL under API & Webhooks in your dashboard and we will POST these events to it as JSON:

EventTypeDescription
call.completed call A call reached its final status (answered, no_answer, busy, failed or rejected). data is the call, as in GET /campaigns/{id}/calls.
campaign.completed campaign Every call in a campaign has finished (or it was cancelled) and unused credits were refunded. data is the campaign.
webhook.test test Sent when you click Send test event in the dashboard.
{
    "id": "evt_9b1f6c1e-6a0e-4a8e-9b0e-0c3d6f2f7a51",
    "event": "call.completed",
    "created_at": "2026-10-08T10:15:42+01:00",
    "data": {
        "id": 9812,
        "campaign_id": 345,
        "phone": "+2348031234567",
        "network": "MTN",
        "status": "answered",
        "duration_seconds": 27,
        "credits_charged": 2,
        "reason": "NORMAL_CLEARING",
        "dialed_at": "2026-10-08T10:15:04+01:00",
        "ended_at": "2026-10-08T10:15:41+01:00"
    }
}

Verify every request. We sign the raw body with your signing secret and send it in X-VoiceSMS-Signature: sha256=<hex>. Compute the same HMAC-SHA256 on your side and compare before trusting the data. The event type is also in X-VoiceSMS-Event.

$body = file_get_contents('php://input');           // the raw body, before json_decode
$expected = 'sha256=' . hash_hmac('sha256', $body, 'whsec_your_signing_secret');
$received = $_SERVER['HTTP_X_VOICESMS_SIGNATURE'] ?? '';

if (! hash_equals($expected, $received)) {
    http_response_code(401);
    exit;
}

$event = json_decode($body, true);
if ($event['event'] === 'call.completed') {
    // $event['data']['phone'], $event['data']['status'], $event['data']['duration_seconds'] ...
}
http_response_code(200);
const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/voicesms-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = 'sha256=' + crypto.createHmac('sha256', 'whsec_your_signing_secret')
    .update(req.body).digest('hex');
  const received = req.get('X-VoiceSMS-Signature') || '';
  if (received.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body);
  console.log(event.event, event.data);
  res.sendStatus(200);
});
import hashlib, hmac, json
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = b"whsec_your_signing_secret"

@app.post("/voicesms-webhook")
def voicesms_webhook():
    body = request.get_data()  # raw bytes
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-VoiceSMS-Signature", "")):
        abort(401)
    event = json.loads(body)
    print(event["event"], event["data"])
    return "", 200
  • Reply with any 2xx status within 10 seconds. Do slow work after replying.
  • Failed deliveries are retried up to 4 more times: after 1 minute, 5 minutes, 15 minutes and 1 hour.
  • An event may occasionally arrive twice; use its id to ignore duplicates. Events can arrive out of order.
  • We do not follow redirects, and the URL must be a public internet address.

Status reference

Campaign statusTypeDescription
scheduled Waiting for its scheduled_at time.
queued Ready; dialling starts shortly (or when the calling window opens).
processing Numbers are being called.
completed Every call has finished and unused credits were refunded.
cancelled Stopped by you; numbers not yet called were skipped and refunded.
Call statusTypeDescription
queued Waiting to be dialled.
dialing The phone is being called.
answered Picked up and the message played. Charged for its duration.
no_answer Rang but was not picked up. Free.
busy The line was busy. Free.
failed Could not be completed (switched off, network error, cancelled). Free. See reason.
rejected Refused by the network. Free.

Questions or need higher limits? Email robocall@infotekps.com.