Developers ยท API v1
Reseller API
Everything in the customer API, plus endpoints to create customer accounts, move credits to and from them, and follow their campaigns โ so you can run voice SMS inside your own platform.
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:
- Create an API key in your dashboard under API & Webhooks.
- Upload your recording once (or upload it in the dashboard). Our team approves it, usually within a few hours.
- Send it to your numbers with
POST /campaigns. - 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 usemultipart/form-data). - Successful responses wrap the result in
data. Lists are paginated: use?page=and?per_page=, and followlinks.nextuntil it isnull. - Times are ISO 8601 in Lagos time (WAT, +01:00). Phone numbers are returned as
+234โฆ.
| HTTP status | Type | Description |
|---|---|---|
| 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": "reseller",
"credits": 1880,
"credit_value_ngn": 11750,
"price_per_credit_ngn": 6.25,
"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.
| Parameters | Type | Description |
|---|---|---|
| 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.
| Parameters | Type | Description |
|---|---|---|
| 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.
Customers Resellers only
Create and fund accounts for your own customers. They pay the price per credit you set in your reseller dashboard, and their credits come from your wallet. A customer can log in with the email and password you set, and create their own API key to send directly.
POST /api/v1/customers
Create an activated customer account under you, optionally with starting credits from your wallet.
| Parameters | Type | Description |
|---|---|---|
| name required | string | Customer or company name. |
| email required | string | Login email; must not already be registered. |
| password required | string | Login password (at least 8 characters). |
| phone | string | Contact number. |
| company | string | Company name. |
| credits | integer | Credits to move from your wallet to the new account. |
curl https://voicesms.infotekps.com/api/v1/customers \
-H "Authorization: Bearer vsk_your_api_key" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"name": "Grace Church Ikeja",
"email": "admin@gracechurch.ng",
"password": "a-strong-password",
"phone": "08031234567",
"credits": 2000
}'
import requests
r = requests.post("https://voicesms.infotekps.com/api/v1/customers",
headers={"Authorization": "Bearer vsk_your_api_key", "Accept": "application/json"},
json={"name": "Grace Church Ikeja", "email": "admin@gracechurch.ng",
"password": "a-strong-password", "phone": "08031234567", "credits": 2000})
print(r.json()["data"]["id"])
{
"data": {
"id": 87,
"name": "Grace Church Ikeja",
"email": "admin@gracechurch.ng",
"phone": "+2348031234567",
"company": null,
"status": "active",
"credits": 2000,
"created_at": "2026-10-08T11:20:00+01:00"
}
}
GET /api/v1/customers
Your customers, newest first. Search with ?search= (name or email).
GET /api/v1/customers/{id}
One customer, including their current credit balance.
POST /api/v1/customers/{id}/credits
Move credits from your wallet to the customer ("action": "add") or take unused credits back ("action": "remove"). The customer is emailed about the change. Returns the customer with the new balance; 422 if either wallet has too few credits.
| Parameters | Type | Description |
|---|---|---|
| action required | string | add or remove. |
| credits required | integer | Number of credits (at least 1). |
curl https://voicesms.infotekps.com/api/v1/customers/87/credits \
-H "Authorization: Bearer vsk_your_api_key" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"action": "add", "credits": 500}'
GET /api/v1/customers/{id}/campaigns
A customer's campaigns, with progress and credit totals.
Webhooks
Set a public https:// URL under API & Webhooks in your dashboard and we will POST these events to it as JSON:
| Event | Type | Description |
|---|---|---|
| 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
2xxstatus 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
idto 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 status | Type | Description |
|---|---|---|
| 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 status | Type | Description |
|---|---|---|
| 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.