Voice Call API
Call a mobile number and play your approved voice recording. Optionally, let the person press keys — and get the answer back.
Use it for order confirmations, payment reminders, appointment reminders, alerts and simple “press 1 to confirm, press 2 to cancel” questions. You choose the recording and the number. SENDPK places the call, plays the recording, and tells you the result: answered or not, how long, which keys were pressed, and what it cost.
API Base URL
Every reply is JSON, including errors. Times are Unix timestamps in seconds. Money is in PKR.
Quick Start
Six steps from nothing to your first call.
Ask SENDPK to switch on voice calls for your account.
Copy your API key from Profile in your dashboard.
Get your recording approved. Check it with templates.php
Send the number and template_id to call.php
SENDPK calls and plays the recording.
Get the result by webhook or status.php
Approved recordings only
A call plays one of your approved recordings. Approval is checked again on every call. Text-to-speech is never used.
Optional key presses
Collect 1–10 keys after the recording, choose which keys count, and get them back in the result.
Fair, simple price
Pulses of 30 seconds of the recording. Not answered? The money goes back (default).
Keep your API key on your server
Call this API from your own server only — never from a mobile app or a browser. Anyone who gets your API key can place calls and spend your balance.
- Never inside an Android or iOS app
- Never in browser JavaScript
- Never in a public Git repository
How It Works
One request from you. One result back. Everything in between is done by SENDPK.
-
Before
Recording approved
Your recording is approved and has an id.
GET templates.php -
Your server
You ask for a call
Number +
template_idtoPOST call.php. The price is taken now. -
SENDPK
The phone rings
We call the number. You get
call.initiated. -
The person
Listens & presses keys
The recording plays. If you asked for keys, they press them.
-
Your server
You get the result
call.completedorcall.failed, with keys and the final charge.
What happens to one call
pendingonly on a 202 replyinitiatedaccepted, ringing or playingcompletedanswered · finalfailednot answered / not placed · final
There are no separate “ringing” or “answered” events. The times ringing_at,
answered_at and ended_at arrive together with the final result.
Integration Guide
Follow these steps in order. Most people finish in under an hour.
Get voice calls switched on
Contact SENDPK support. We switch voice calls on for your account and set your price per pulse.
Your account must be verified. Until then you get 403 SERVICE_DISABLED.
Get your API key and whitelist your server
Copy the key from Profile → API Key. If IP restriction is on, add your server IP there.
Check your key and your recordings
Call GET templates.php. It is free and places no call. Note the id of a recording with
"status": "approved".
Build your webhook endpoint
An https URL on your server that checks the signature and answers 200
within 5 seconds. Skip this if you prefer to poll status.php.
Call your own phone
Send one POST call.php to your own number, with an Idempotency-Key. Use the
Request Builder to make the code. Save data.id.
Handle the final result
Read status, outcome, dtmf.digits and charge from
call.completed / call.failed (or from status.php).
Go live safely
Retry only with the same Idempotency-Key, respect Retry-After, skip repeated event_ids,
and log meta.request_id. See Best Practices.
Authentication
One header. Your normal SENDPK API key (the same key as the SMS API) works here too.
Send your key in the Authorization header
This form also works:
The key is not accepted anywhere else
| Where | Accepted? |
|---|---|
Authorization header | YES — the only way. |
| URL / query string | NO — ignored, you get AUTH_MISSING. |
| JSON body | NO — refused as an unknown field. |
| Username / password | NO — not supported. |
What we check, in this order
| Check | If it fails |
|---|---|
| HTTPS | 403 HTTPS_REQUIRED |
| Too many wrong keys from your IP | 429 RATE_LIMIT_EXCEEDED (details.scope = "auth", wait 30 minutes) |
| Key is right | 401 AUTH_MISSING / 401 AUTH_INVALID |
| Account not blocked | 403 ACCOUNT_DISABLED |
| Key not marked “must change” | 401 API_KEY_REVOKED |
| IP whitelist (if on) | 403 IP_NOT_ALLOWED — details.ip shows the IP we saw |
| Voice calls switched on | 403 SERVICE_DISABLED |
| Account verified | 403 ACCOUNT_NOT_VERIFIED |
HTTPS is required
Always use https://. A plain http:// request is refused with 403 HTTPS_REQUIRED,
because over http anyone on the way could read your key.
Base URL & Endpoints
Three endpoints. That is the whole API.
| Method | Endpoint | What it does | Cost |
|---|---|---|---|
| GET | /templates.php |
List your voice recordings (or one with ?id=) and their approval status. |
FREE |
| POST | /call.php |
Place one call that plays a recording, with optional key presses. | pulses × price |
| GET | /status.php?id=CALL_ID |
The current state of one call: result, keys, times, charge, webhook delivery. | FREE |
Response format
Every reply has the same shape, so your code can always read it the same way.
SUCCESS
{
"success": true,
"data": {},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e60" }
}ERROR
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Calls per minute limit exceeded.",
"details": { "scope": "account", "limit": 30, "retry_after_seconds": 41 }
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e60" }
}| Field | Meaning |
|---|---|
success | true or false. Check this first. |
data | The result, when success is true. |
error | The problem: a code for your program, a message for humans, and details. |
meta.request_id | The id of this one HTTP request (also in the X-Request-Id header). Not the call id. |
Save request_id and give it to SENDPK support when something goes wrong — we can find your exact request with it.
When details.retry_after_seconds is present, a Retry-After header is sent too.
Voice Templates GET
A voice template is one of your recordings. Only approved recordings can be played. This endpoint is read-only: it shows what you have and whether each one can be used.
| Request | Returns |
|---|---|
GET templates.php | All your recordings, newest first: data.templates + data.count. |
GET templates.php?id=3301118 | One recording: data.template. Not yours or not found = 404 TEMPLATE_NOT_FOUND. |
Example request
curl -s https://sendpk.com/apps/phone_calls/templates.php \
-H "Authorization: YOUR_API_KEY"<?php
$ch = curl_init('https://sendpk.com/apps/phone_calls/templates.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Authorization: ' . getenv('SENDPK_API_KEY')],
]);
$reply = json_decode((string) curl_exec($ch), true);
foreach ($reply['data']['templates'] ?? [] as $t) {
if ($t['usable']) {
echo $t['id'] . ' - ' . $t['title'] . ' (' . $t['duration'] . " s)\n";
}
}// Node.js 18+ (server-side)
const res = await fetch('https://sendpk.com/apps/phone_calls/templates.php', {
headers: { 'Authorization': process.env.SENDPK_API_KEY },
});
const reply = await res.json();
const usable = (reply.data?.templates ?? []).filter(t => t.usable);
console.log(usable.map(t => `${t.id} - ${t.title} (${t.duration} s)`));import os
import requests
res = requests.get(
"https://sendpk.com/apps/phone_calls/templates.php",
headers={"Authorization": os.environ["SENDPK_API_KEY"]},
timeout=15,
)
for t in res.json().get("data", {}).get("templates", []):
if t["usable"]:
print(t["id"], "-", t["title"], f'({t["duration"]} s)')Example response HTTP 200
{
"success": true,
"data": {
"templates": [
{
"id": 3301120,
"title": "Eid offer",
"duration": 35,
"status": "rejected",
"usable": false,
"rejection_reason": "Background music too loud",
"created_on": "2026-10-05"
},
{
"id": 3301118,
"title": "Order confirmation",
"duration": 50,
"status": "approved",
"usable": true,
"rejection_reason": null,
"created_on": "2026-10-01"
}
],
"count": 2
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e61" }
}| Field | Meaning |
|---|---|
id | Use this as template_id in call.php. |
title | The name of the recording. |
duration | Length of the recording in seconds. This decides the price (see Billing). |
status | approved (can be played), pending (waiting for SENDPK), rejected. |
usable | true only for approved. |
rejection_reason | Why SENDPK rejected it (only for rejected), else null. |
created_on | Date the recording was added (YYYY-MM-DD), or null. |
Good to know
Recordings are reviewed by SENDPK. To add a new recording, contact SENDPK support. It shows here as
pending until it is reviewed.
Approval is checked on every call. If a recording stops being approved, the next call with it is refused
(TEMPLATE_PENDING, TEMPLATE_REJECTED or TEMPLATE_NOT_APPROVED) and nothing is charged.
Maximum length: 240 seconds (8 pulses). You only ever see your own recordings.
Place a Call POST
Headers
| Header | Value |
|---|---|
Authorization required | Your API key. |
Content-Type required | application/json |
Idempotency-Key optional | Any unique text, 1–100 visible ASCII characters, no spaces. Strongly recommended — see Safe Retries. |
Body fields (JSON object, max 8 KB)
| Field | Type | Description |
|---|---|---|
number required |
string | The Pakistani mobile number to call. Best: +923001234567.
Also accepted: 923001234567, 00923001234567, 03001234567, 3001234567.
Spaces, dashes and brackets are ignored. |
template_id required* |
integer | The id of one of your approved recordings (from templates.php). |
audio_url free form only |
string | Your own https .wav / .mp3 link, instead of template_id. Only for accounts SENDPK switched to
free form. See Own Audio Link. |
dtmf optional |
object | boolean | Collect key presses. true = defaults, or an object. Leave out (or false) for no keys.
See Key Presses. |
notification_callback optional |
string | Your webhook URL for this call: a public https address on port 443 or 8443, max 500 characters.
See Webhooks. |
* Send one of template_id or audio_url. Both = 422 CONFLICTING_FIELDS.
Any other field = 422 UNKNOWN_FIELD. Text-to-speech and audio fields (text, tts,
url, recording…) = 422 AUDIO_NOT_ALLOWED.
Example request
curl -s -X POST https://sendpk.com/apps/phone_calls/call.php \
-H "Authorization: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-58213-call-1" \
-d '{
"number": "+923001234567",
"template_id": 3301118,
"dtmf": {"max_digits": 1, "timeout": 10, "valid_digits": "12"},
"notification_callback": "https://example.com/webhooks/voice-call"
}'<?php
$ch = curl_init('https://sendpk.com/apps/phone_calls/call.php');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: ' . getenv('SENDPK_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-58213-call-1',
],
CURLOPT_POSTFIELDS => json_encode([
'number' => '+923001234567',
'template_id' => 3301118,
'dtmf' => ['max_digits' => 1, 'timeout' => 10, 'valid_digits' => '12'],
'notification_callback' => 'https://example.com/webhooks/voice-call',
]),
]);
$reply = json_decode((string) curl_exec($ch), true);
$callId = $reply['data']['id'] ?? null; // save it// Node.js 18+ (server-side)
const res = await fetch('https://sendpk.com/apps/phone_calls/call.php', {
method: 'POST',
headers: {
'Authorization': process.env.SENDPK_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-58213-call-1',
},
body: JSON.stringify({
number: '+923001234567',
template_id: 3301118,
dtmf: { max_digits: 1, timeout: 10, valid_digits: '12' },
notification_callback: 'https://example.com/webhooks/voice-call',
}),
});
const reply = await res.json();
const callId = reply.success ? reply.data.id : null; // save itimport os
import requests
res = requests.post(
"https://sendpk.com/apps/phone_calls/call.php",
headers={
"Authorization": os.environ["SENDPK_API_KEY"],
"Idempotency-Key": "order-58213-call-1",
},
json={
"number": "+923001234567",
"template_id": 3301118,
"dtmf": {"max_digits": 1, "timeout": 10, "valid_digits": "12"},
"notification_callback": "https://example.com/webhooks/voice-call",
},
timeout=30,
)
reply = res.json()
call_id = reply["data"]["id"] if reply["success"] else None # save itExample response HTTP 200
{
"success": true,
"data": {
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"status": "initiated",
"outcome": null,
"number": "+923001234567",
"template": { "id": 3301118, "title": "Order confirmation", "duration": 50 },
"audio_url": null,
"recording_seconds": 50,
"dtmf": { "enabled": true, "max_digits": 1, "timeout": 10, "valid_digits": "12", "digits": null, "valid": null },
"created_at": 1770000000,
"ringing_at": null,
"answered_at": null,
"ended_at": null,
"duration": null,
"charge": { "status": "charged", "final": false, "price": 6.0, "pulses": 2, "price_per_pulse": 3.0, "paid": 6.0, "refunded": 0.0, "currency": "PKR" },
"error_code": null,
"reused": false
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e62" }
}
PKR 3.00 per pulse is only an example. Your real price per pulse is in charge.price_per_pulse.
| Reply | Meaning | What you do |
|---|---|---|
200 initiated | The call is accepted and will ring now. The full price is taken (charge.final = false). | Save data.id. Wait for the webhook. |
202 pending | We sent the call but did not get a confirmation in time. | Do not send it again (unless with the same Idempotency-Key). We check it ourselves within a few minutes. |
200 reused: true | An earlier call came back: same Idempotency-Key, or the same request to a number that is still being called. | Nothing. No second call, no second charge. |
| 4xx | Refused. No call was made and nothing was charged. | Fix the request (see Error Codes). |
| 5xx / no reply | Something failed on our side or on the network. | Retry with the same Idempotency-Key. |
What we check before the phone rings
In this order. The first problem found is the error you get — and nothing is charged:
- The body: only known fields, a valid
number,template_id/audio_url,dtmf,notification_callback, Idempotency-Key. - Is this a repeat? (same Idempotency-Key, or the same number still being called).
- Your limits: per minute, at the same time, per day, per number.
- The recording: yours, approved, not longer than 240 seconds, file reachable.
- Recording + key-press wait fits in one call.
- Balance: one of your PKR packages can pay the full price.
Own Audio Link FREE-FORM ACCOUNTS
If SENDPK has switched free form on for your account, you may play your own audio file
without approval: send audio_url instead of template_id.
curl -s -X POST https://sendpk.com/apps/phone_calls/call.php \
-H "Authorization: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: offer-october-923001234567" \
-d '{"number": "+923001234567", "audio_url": "https://cdn.example.com/voice/offer-october.mp3"}'In the reply template is null, audio_url is your link and
recording_seconds is the length we measured:
{
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"status": "initiated",
"template": null,
"audio_url": "https://cdn.example.com/voice/offer-october.mp3",
"recording_seconds": 75,
"dtmf": { "enabled": false },
"charge": { "status": "charged", "final": false, "price": 9.0, "pulses": 3, "price_per_pulse": 3.0, "paid": 9.0, "refunded": 0.0, "currency": "PKR" }
}What we do with your link
Rules for audio_url
| Rule | Detail |
|---|---|
Public https link | Ending in .wav or .mp3 (a query string is fine, e.g. a signed link). Port 443 or 8443. |
| Max 255 characters | Spaces and < > " ' ` { } | ^ \ must be percent-encoded (a space as %20). |
| Address | No user:password, no private or internal addresses, no SENDPK addresses. |
| File size | Under 4 MB. |
| Length | At most 240 seconds. |
| WAV | Correct RIFF/WAVE header; PCM, IEEE float, A-law or mu-law audio. |
| MP3 | Complete, continuous MPEG Layer III frames (ID3 tags are fine). |
If the link is not OK: 422 INVALID_AUDIO_URL
details.reason | Meaning |
|---|---|
| (no reason) | The link itself breaks a rule above (not https, too long, not .wav/.mp3, bad characters, blocked address). |
unreachable | We could not connect or download it in time. |
blocked_address | The link points at a private, internal or SENDPK address. |
http_404, http_301… | Your server did not answer 200 (redirects are not followed). |
too_large | The file is 4 MB or more. |
not_audio | Not real WAV/MP3, cut off, or an unsupported encoding. Export it again as PCM WAV or MP3. |
too_long | Longer than 240 seconds (details.seconds, details.max_seconds). |
A bad link costs nothing. Without free form, audio_url is refused with 422 AUDIO_NOT_ALLOWED.
Approved recordings (template_id) keep working as before.
Key Presses (DTMF)
DTMF (the tones a phone makes when you press a key) lets the person answer your recording — for example “press 1 to confirm, press 2 to cancel”.
"dtmf": { "max_digits": 1, "timeout": 10, "valid_digits": "12" }| Field | Default | Allowed | Meaning |
|---|---|---|---|
max_digits | 1 | 1–10 | How many keys to collect. |
timeout | 10 | 1–30 seconds | How long to wait for keys after the recording ends. |
valid_digits | any key | up to 11 of 0–9 and * | The keys that count as a valid answer, e.g. "12". |
Rules
"dtmf": true Uses the defaults (1 key, 10 seconds, any key).dtmf / false No keys are collected. The reply shows "dtmf": {"enabled": false}.# ends the input So # cannot be one of the valid_digits.valid_digits is saved without repeats, in order: "21" is shown back as "12".timeout must be 245 seconds or less, else 422 INVALID_DTMF.
Example: a 240 s recording allows a timeout of up to 5 s.Reading the answer
dtmf.digits | dtmf.valid | Meaning |
|---|---|---|
null | null | The call is still running, or the keys could not be read. |
"" | false | Nothing was pressed. |
"1" | true | The person pressed 1, which is in valid_digits. |
"7" | false | A key outside valid_digits was pressed. |
valid is true only when at least one key was pressed and every key was in valid_digits
(any key counts when you did not set valid_digits). A # at the end is ignored.
Call Status GET
Example request
curl -s "https://sendpk.com/apps/phone_calls/status.php?id=0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f" \
-H "Authorization: YOUR_API_KEY"The reply has the same fields as the call.php reply (without reused), plus webhook.
Pick an example:
{
"success": true,
"data": {
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"status": "completed",
"outcome": "answered",
"number": "+923001234567",
"template": { "id": 3301118, "title": "Order confirmation", "duration": 50 },
"audio_url": null,
"recording_seconds": 50,
"dtmf": { "enabled": true, "max_digits": 1, "timeout": 10, "valid_digits": "12", "digits": "1", "valid": true },
"created_at": 1770000000,
"ringing_at": 1770000001,
"answered_at": 1770000005,
"ended_at": 1770000062,
"duration": 57,
"charge": { "status": "charged", "final": true, "price": 6.0, "pulses": 2, "price_per_pulse": 3.0, "paid": 6.0, "refunded": 0.0, "currency": "PKR" },
"error_code": null,
"webhook": { "status": "sent", "last_event": "call.completed", "delivered": 2, "last_http_code": 200 }
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e63" }
}{
"success": true,
"data": {
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"status": "failed",
"outcome": "no_answer",
"number": "+923001234567",
"template": { "id": 3301118, "title": "Order confirmation", "duration": 19 },
"audio_url": null,
"recording_seconds": 19,
"dtmf": { "enabled": false },
"created_at": 1770000000,
"ringing_at": 1770000001,
"answered_at": null,
"ended_at": null,
"duration": 0,
"charge": { "status": "refunded", "final": true, "price": 3.0, "pulses": 1, "price_per_pulse": 3.0, "paid": 0.0, "refunded": 3.0, "currency": "PKR" },
"error_code": null,
"webhook": { "status": "sent", "last_event": "call.failed", "delivered": 2, "last_http_code": 200 }
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e64" }
}{
"success": true,
"data": {
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"status": "initiated",
"outcome": null,
"number": "+923001234567",
"template": { "id": 3301118, "title": "Order confirmation", "duration": 50 },
"audio_url": null,
"recording_seconds": 50,
"dtmf": { "enabled": true, "max_digits": 1, "timeout": 10, "valid_digits": "12", "digits": null, "valid": null },
"created_at": 1770000000,
"ringing_at": null,
"answered_at": null,
"ended_at": null,
"duration": null,
"charge": { "status": "charged", "final": false, "price": 6.0, "pulses": 2, "price_per_pulse": 3.0, "paid": 6.0, "refunded": 0.0, "currency": "PKR" },
"error_code": null,
"webhook": { "status": "sent", "last_event": "call.initiated", "delivered": 1, "last_http_code": 200 }
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e65" }
}{
"success": true,
"data": {
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"status": "failed",
"outcome": "unknown",
"number": "+923001234567",
"template": { "id": 3301118, "title": "Order confirmation", "duration": 50 },
"audio_url": null,
"recording_seconds": 50,
"dtmf": { "enabled": false },
"created_at": 1770000000,
"ringing_at": null,
"answered_at": null,
"ended_at": null,
"duration": null,
"charge": { "status": "refunded", "final": true, "price": 6.0, "pulses": 2, "price_per_pulse": 3.0, "paid": 0.0, "refunded": 6.0, "currency": "PKR" },
"error_code": "PROVIDER_UNKNOWN",
"webhook": { "status": "none", "last_event": "call.failed", "delivered": 0, "last_http_code": null }
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e66" }
}| Field | Meaning |
|---|---|
id | The call id. |
status | pending, initiated, completed or failed. See Statuses & Outcomes. |
outcome | What happened (answered, no_answer, busy…). null until the call is final. |
number | The number called, in +92… form. |
template | The recording played (id, title, duration), or null for an audio link. |
audio_url | Your own link (free form), or null for an approved recording. |
recording_seconds | Length of the recording. This decides the pulses. |
dtmf | Key-press settings and the answer. See Key Presses. |
created_at | When you submitted the call. |
ringing_at / answered_at / ended_at | When it rang, was answered, ended. null until the result arrives (or if it did not happen). |
duration | Seconds the call was connected (final calls only). For information — it does not change the price. |
charge | The money. See Billing. |
error_code | PROVIDER_UNKNOWN (we could not confirm the call was placed) or REPORT_MISSING (no result in 48 hours). Both free. Otherwise null. |
webhook.status | none (no webhook URL), pending, sent or failed. |
webhook.last_event / delivered / last_http_code | The last event, how many events reached you, and your server's last HTTP answer. |
You only see your own calls: another account's id gives 404 CALL_NOT_FOUND. Status is read from SENDPK's records,
so polling is fine — but not more than once every few seconds. Webhooks are better.
Statuses & Outcomes
status tells you where the call is. outcome tells you what happened.
status
| status | Meaning | Final? |
|---|---|---|
pending | Sent, waiting for confirmation (only after a 202 reply). | no |
initiated | Accepted. Ringing or playing. The result comes when the call ends. | no |
completed | Answered: the recording was played. | yes |
failed | Not answered, or could not be placed. See outcome and error_code. | yes |
outcome and what happens to the money
| outcome | Meaning | Normal account | “Keep unanswered” account |
|---|---|---|---|
answered | A person answered; the recording was played. | kept | kept |
answered_machine | Voicemail / answering machine picked up; the recording was played. | kept | kept |
no_answer | Nobody answered. | given back | kept |
busy | The line was busy. | given back | kept |
rejected | The call was declined. | given back | kept |
unreachable | Phone off or out of coverage. | given back | kept |
invalid_number | The number does not exist. | given back | kept |
not_answered | Not answered (no more detail available). | given back | kept |
expired | Could not be placed within 10 minutes. | given back | kept |
network_error | A problem on the phone network. | given back | kept |
unknown | The network gave no clear result. | given back | kept |
unknown + error_code | PROVIDER_UNKNOWN: we could not confirm the call was placed. REPORT_MISSING: no result in 48 hours. | always given back | always given back |
recording_error | The recording could not be played. For an approved recording SENDPK is alerted; for your own link, check the file. | always given back | always given back |
service_error | A problem on our side. | always given back | always given back |
Every account is a normal account unless SENDPK has set it to keep charges for unanswered calls.
A call the network refuses at once is never a call: you get an error reply (e.g. 502 PROVIDER_REJECTED) and the money goes straight back.
A late result can still change a failed call
Rarely, the real result arrives after we closed a call as PROVIDER_UNKNOWN or REPORT_MISSING.
The call is then updated with the real result. If it was answered, the price is taken again and you get a
call.completed webhook after the earlier call.failed. Always trust the newest status.
Billing
You pay for 30-second pulses of the recording. Every started pulse counts.
Pulses = recording ÷ 30 s
Rounded up. Price = pulses × your price per pulse.
Taken when you submit
The full price comes from one PKR package that can pay all of it.
Back if not answered DEFAULT
Answered (or voicemail) = kept. Otherwise given back.
Examples at PKR 3.00 per pulse
| Recording | Pulses | Price | |
|---|---|---|---|
| 20 s | 1 | PKR 3.00 | any length up to 30 s is one pulse |
| 30 s | 1 | PKR 3.00 | exactly one pulse |
| 50 s | 2 | PKR 6.00 | the 2nd pulse started |
| 75 s | 3 | PKR 9.00 | 1 min 15 s |
PKR 3.00 is only an example. Your price per pulse is set on your package by SENDPK. Try your own numbers in the Price Calculator.
What does not change the price
dtmf.timeout seconds are free.The charge object
| Field | Meaning |
|---|---|
status | See the table below. |
final | false while the call runs (the money may still come back); true once it is final. |
price | Pulses × price per pulse, worked out at submission. |
pulses / price_per_pulse | The two numbers behind price. |
paid | What you pay right now (the price, or 0 once given back). |
refunded | What was given back to your balance. |
currency | Always PKR. |
charge.status
| status | Meaning |
|---|---|
charged | The money is taken — while the call runs, and afterwards if it is kept. |
refunded | The money was given back to your balance (to the package it came from). |
short | Rare: a late result needed the money again, but your balance was too low, so what was left was taken. |
refund_pending | Rare: we could not give it back automatically (no PKR package left). SENDPK support will do it. |
One package must pay all of it
The full price must fit in one of your PKR voice packages. If none can pay it you get
402 INSUFFICIENT_BALANCE with details.required (the price) and details.available
(your biggest single package), and no call is made. Do not treat paid as final while charge.final is false.
Safe Retries with Idempotency-Key
If your request timed out, you do not know if the phone is already ringing. Send it again with the
same Idempotency-Key and nobody is called twice.
| What you send | What happens |
|---|---|
| Same key + same body (within 24 h) | The same call comes back (reused: true). No second call, no second charge. |
| Same key + different body | 409 IDEMPOTENCY_CONFLICT. Use a new key for a new call. |
| The first try was refused by the call network | The retry gets the same error (e.g. 503 PROVIDER_UNAVAILABLE) and no new call is made. Use a new key to try again. |
The simple rule
order-58213-call-1. Keys are per account.Same number still being called
While a call to a number is pending / initiated (up to 2 minutes after it started):
the same request again returns that call (reused: true); a different request
to that number gets 409 NUMBER_BUSY with details.id = the running call.
Limits
Per account, for all your requests together. Need more? Contact SENDPK support.
| Limit | Value | Error when you pass it |
|---|---|---|
| New calls per minute (any 60 seconds) | 30 | 429 RATE_LIMIT_EXCEEDED (details.scope = "account") |
| Calls running at the same time | 20 | 429 CONCURRENT_LIMIT_REACHED |
| Calls per day | 2,000 | 429 DAILY_LIMIT_REACHED |
| Calls to the same number per day | 5 | 429 NUMBER_DAILY_LIMIT_REACHED |
| New calls per IP address per minute (all accounts) | 60 | 429 RATE_LIMIT_EXCEEDED (details.scope = "ip") |
| Failed API key attempts | 5 / 30 min per IP + key, 20 / 10 min per IP | 429 RATE_LIMIT_EXCEEDED (details.scope = "auth") |
| Recording length | 240 s (8 pulses) | 422 TEMPLATE_INVALID (audio link: INVALID_AUDIO_URL / too_long) |
| Recording + key-press timeout | 245 s | 422 INVALID_DTMF |
| Request body | 8 KB | 413 PAYLOAD_TOO_LARGE |
Good to know
details.retry_after_seconds and a Retry-After header. Wait that long.Webhooks
Send notification_callback with the call and SENDPK tells your server what happened —
no need to keep checking Status.
Events
| Event | When it is sent |
|---|---|
call.initiated | The call was accepted and will ring. |
call.completed | The call was answered and the recording played. The charge is final. |
call.failed | The call ended without an answer, or could not be placed. The charge is final. |
Order: call.initiated first, then one final event. If call.initiated could not be
delivered and the result is already known, you get only the final event (it holds everything). A very short call may also send only
the final event. The final event is sent after the money is settled, so its charge is final.
Headers we send
POST /webhooks/voice-call HTTP/1.1
Content-Type: application/json
User-Agent: SENDPK-Webhook/1.0
X-SENDPK-Event: call.completed
X-SENDPK-Event-Id: 3c2384b7-580f-52fc-b246-aea4f0a32eb5
X-SENDPK-Signature: sha256=<hex HMAC-SHA256 of the raw body>Body we send
The call fields (same as Status, without webhook), plus event,
event_id and timestamp. Shown here with line breaks; the real body is one compact line.
{
"event": "call.completed",
"event_id": "3c2384b7-580f-52fc-b246-aea4f0a32eb5",
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"status": "completed",
"outcome": "answered",
"number": "+923001234567",
"template": { "id": 3301118, "title": "Order confirmation", "duration": 50 },
"audio_url": null,
"recording_seconds": 50,
"dtmf": { "enabled": true, "max_digits": 1, "timeout": 10, "valid_digits": "12", "digits": "1", "valid": true },
"created_at": 1770000000,
"ringing_at": 1770000001,
"answered_at": 1770000005,
"ended_at": 1770000062,
"duration": 57,
"charge": { "status": "charged", "final": true, "price": 6.0, "pulses": 2, "price_per_pulse": 3.0, "paid": 6.0, "refunded": 0.0, "currency": "PKR" },
"error_code": null,
"timestamp": 1770000063
}Your webhook URL must be
| Rule | Detail |
|---|---|
Public https | Plain http is refused. |
| Port 443 or 8443 | No other port. |
| Max 500 characters | Longer URLs are refused. |
| Reachable from the internet | No localhost, no private / internal addresses (10.x, 192.168.x, 127.x…). |
| Not SENDPK | A webhook may not point back at SENDPK's own domains. |
| Clean URL | No user:password, no \ or @. The address is checked again right before every send. |
A URL that breaks a rule is refused with 422 INVALID_CALLBACK_URL, before any call is made.
Delivery and retries
2xx status within 5 seconds. Do slow work after you answer.408, 429 or 5xx: after 1 min, 5 min, 15 min and 1 hour (5 tries in total).4xx replies and redirects.event_id stays the same for the same event — use it to skip repeats.call.initiated can already show the final status. Trust status, not only event.status.php shows webhook.status: none, pending, sent, failed.Webhook Security
The signature proves the webhook came from SENDPK and was not changed on the way.
We sign the exact raw body with HMAC-SHA256, using your API key as the secret, and send it
in X-SENDPK-Signature. Your server does the same sum and compares. No match = not from us: throw it away.
<?php
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('SENDPK_API_KEY'));
if (!hash_equals($expected, $_SERVER['HTTP_X_SENDPK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// Skip repeats: have you already handled $event['event_id']?
http_response_code(200);const crypto = require('crypto');
// rawBody = the exact bytes received (e.g. express.raw({ type: 'application/json' }))
function isFromSendpk(rawBody, header) {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.SENDPK_API_KEY)
.update(rawBody)
.digest('hex');
const got = header || '';
return got.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got));
}import hashlib
import hmac
import os
def is_from_sendpk(raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(
os.environ["SENDPK_API_KEY"].encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header or "")Test your code with these fixed values
Run your check with this test key and this exact body (one line, no spaces added). It must produce the signature below. The values are made up — they are not a real key or a real call.
{"event":"call.completed","event_id":"3c2384b7-580f-52fc-b246-aea4f0a32eb5","id":"0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f","status":"completed","outcome":"answered","number":"+923001234567","template":{"id":3301118,"title":"Order confirmation","duration":50},"audio_url":null,"recording_seconds":50,"dtmf":{"enabled":true,"max_digits":1,"timeout":10,"valid_digits":"12","digits":"1","valid":true},"created_at":1770000000,"ringing_at":1770000001,"answered_at":1770000005,"ended_at":1770000062,"duration":57,"charge":{"status":"charged","final":true,"price":6.0,"pulses":2,"price_per_pulse":3.0,"paid":6.0,"refunded":0.0,"currency":"PKR"},"error_code":null,"timestamp":1770000063}Use the raw body, always
Never JSON-decode and re-encode the body before checking. One changed space gives a completely different signature.
If you change your API key, use the new key from that moment on.
Request Builder
Fill in the form. The code below updates as you type — ready to copy.
Your code
Loading...Loading...Loading...Loading...Loading...
The code reads your key from the SENDPK_API_KEY environment variable, so the key never sits in your code.
Price Calculator
See the pulses and the price of one call before you send it.
Code Examples
A complete, small integration: place a call, receive the result, and fall back to Status if a webhook is late.
All examples run on your server
The JavaScript examples are Node.js, not browser code. Put your key in the
SENDPK_API_KEY environment variable.
1. Place a call (with safe retry)
<?php
/**
* Place one call. Retries on a timeout or a 5xx with the SAME Idempotency-Key,
* so the person is never called twice.
*/
function sendpk_voice_call(string $number, int $templateId, string $idempotencyKey, array $extra = []): array
{
$body = json_encode(['number' => $number, 'template_id' => $templateId] + $extra);
for ($try = 1; $try <= 3; $try++) {
$ch = curl_init('https://sendpk.com/apps/phone_calls/call.php');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: ' . getenv('SENDPK_API_KEY'),
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencyKey,
],
CURLOPT_POSTFIELDS => $body,
]);
$raw = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$reply = is_string($raw) ? json_decode($raw, true) : null;
if (is_array($reply) && $http < 500) {
return $reply; // success, or a 4xx you must fix
}
sleep($try * 2); // no reply or 5xx: same key, try again
}
return ['success' => false, 'error' => ['code' => 'NO_REPLY', 'message' => 'No reply from SENDPK']];
}
$reply = sendpk_voice_call('+923001234567', 3301118, 'order-58213-call-1', [
'dtmf' => ['max_digits' => 1, 'timeout' => 10, 'valid_digits' => '12'],
'notification_callback' => 'https://example.com/webhooks/voice-call',
]);
if ($reply['success']) {
// Save $reply['data']['id'] with your order.
echo 'Call id: ' . $reply['data']['id'] . ' (' . $reply['data']['status'] . ")\n";
} else {
echo 'Not placed: ' . $reply['error']['code'] . ' - ' . $reply['error']['message'] . "\n";
}// Node.js 18+. Retries on a timeout or a 5xx with the SAME Idempotency-Key.
async function sendpkVoiceCall(number, templateId, idempotencyKey, extra = {}) {
const body = JSON.stringify({ number, template_id: templateId, ...extra });
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const res = await fetch('https://sendpk.com/apps/phone_calls/call.php', {
method: 'POST',
headers: {
'Authorization': process.env.SENDPK_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body,
signal: AbortSignal.timeout(30000),
});
if (res.status < 500) {
return await res.json(); // success, or a 4xx you must fix
}
} catch (err) {
// timeout / network error: retry with the same key
}
await new Promise(r => setTimeout(r, attempt * 2000));
}
return { success: false, error: { code: 'NO_REPLY', message: 'No reply from SENDPK' } };
}
const reply = await sendpkVoiceCall('+923001234567', 3301118, 'order-58213-call-1', {
dtmf: { max_digits: 1, timeout: 10, valid_digits: '12' },
notification_callback: 'https://example.com/webhooks/voice-call',
});
console.log(reply.success ? `Call id: ${reply.data.id}` : `Not placed: ${reply.error.code}`);import os
import time
import requests
def sendpk_voice_call(number, template_id, idempotency_key, **extra):
"""Retries on a timeout or a 5xx with the SAME Idempotency-Key."""
body = {"number": number, "template_id": template_id, **extra}
for attempt in range(1, 4):
try:
res = requests.post(
"https://sendpk.com/apps/phone_calls/call.php",
headers={
"Authorization": os.environ["SENDPK_API_KEY"],
"Idempotency-Key": idempotency_key,
},
json=body,
timeout=30,
)
if res.status_code < 500:
return res.json() # success, or a 4xx you must fix
except requests.RequestException:
pass # timeout / network error: retry with the same key
time.sleep(attempt * 2)
return {"success": False, "error": {"code": "NO_REPLY", "message": "No reply from SENDPK"}}
reply = sendpk_voice_call(
"+923001234567", 3301118, "order-58213-call-1",
dtmf={"max_digits": 1, "timeout": 10, "valid_digits": "12"},
notification_callback="https://example.com/webhooks/voice-call",
)
print(reply["data"]["id"] if reply["success"] else reply["error"]["code"])2. Receive the webhook
<?php
// https://example.com/webhooks/voice-call
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('SENDPK_API_KEY'));
if (!hash_equals($expected, $_SERVER['HTTP_X_SENDPK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$call = json_decode($raw, true);
// 1. Skip repeats (store handled event ids in your database).
// if (already_handled($call['event_id'])) { http_response_code(200); exit; }
// 2. Act on the final result only. Trust "status", not only "event".
if ($call['status'] === 'completed') {
$key = $call['dtmf']['digits'] ?? null; // "1", "2", "" or null
// e.g. $key === '1' -> order confirmed, '2' -> cancelled
} elseif ($call['status'] === 'failed') {
// $call['outcome']: no_answer, busy, unreachable, ... -> try SMS instead?
}
// $call['charge']['paid'] is what you paid for this call.
http_response_code(200); // answer fast (within 5 seconds)// Node.js + Express
const express = require('express');
const crypto = require('crypto');
const app = express();
app.post('/webhooks/voice-call', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.SENDPK_API_KEY)
.update(req.body) // raw bytes, NOT re-encoded JSON
.digest('hex');
const got = req.get('X-SENDPK-Signature') || '';
if (got.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got))) {
return res.sendStatus(401);
}
const call = JSON.parse(req.body.toString('utf8'));
// Skip repeats with call.event_id, then act on the final result:
if (call.status === 'completed') {
console.log('Answered. Key pressed:', call.dtmf.digits);
} else if (call.status === 'failed') {
console.log('Not answered:', call.outcome, 'refunded:', call.charge.refunded);
}
res.sendStatus(200); // answer fast (within 5 seconds)
});
app.listen(3000);# Python + Flask
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks/voice-call")
def voice_call_webhook():
raw = request.get_data() # raw bytes, NOT re-encoded JSON
expected = "sha256=" + hmac.new(
os.environ["SENDPK_API_KEY"].encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-SENDPK-Signature", "")):
abort(401)
call = json.loads(raw)
# Skip repeats with call["event_id"], then act on the final result:
if call["status"] == "completed":
print("Answered. Key pressed:", call["dtmf"].get("digits"))
elif call["status"] == "failed":
print("Not answered:", call["outcome"])
return "", 200 # answer fast (within 5 seconds)3. Check the status (fallback when a webhook is late)
curl -s "https://sendpk.com/apps/phone_calls/status.php?id=0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f" \
-H "Authorization: YOUR_API_KEY"<?php
$id = '0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f';
$ch = curl_init('https://sendpk.com/apps/phone_calls/status.php?id=' . rawurlencode($id));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => ['Authorization: ' . getenv('SENDPK_API_KEY')],
]);
$call = json_decode((string) curl_exec($ch), true)['data'] ?? null;
if ($call && in_array($call['status'], ['completed', 'failed'], true)) {
echo $call['outcome'] . ', paid PKR ' . $call['charge']['paid'] . "\n";
}const id = '0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f';
const res = await fetch(`https://sendpk.com/apps/phone_calls/status.php?id=${encodeURIComponent(id)}`, {
headers: { 'Authorization': process.env.SENDPK_API_KEY },
});
const { data: call } = await res.json();
if (call && ['completed', 'failed'].includes(call.status)) {
console.log(call.outcome, 'paid PKR', call.charge.paid);
}import os
import requests
res = requests.get(
"https://sendpk.com/apps/phone_calls/status.php",
params={"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f"},
headers={"Authorization": os.environ["SENDPK_API_KEY"]},
timeout=15,
)
call = res.json().get("data")
if call and call["status"] in ("completed", "failed"):
print(call["outcome"], "paid PKR", call["charge"]["paid"])Postman Collection
Try every endpoint in Postman without writing code.
What is inside
GET templates.php — free, places no call.GET templates.php?id=POST call.php — costs money; saves the call id for you.POST call.php with dtmf.GET status.php?id={{call_id}}
Put your key in the collection's api_key variable. Never share the collection after you filled it in.
Error Codes
Switch on error.code in your program. The message is for humans.
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
| Request format | |||
| 400 | BAD_REQUEST |
The request could not be understood. | Check the URL, method and body. |
| 400 | INVALID_JSON |
The body is not a valid JSON object. | Send one JSON object, e.g. {"number": "..."}. |
| 400 | INVALID_IDEMPOTENCY_KEY |
Idempotency-Key has spaces or non-ASCII characters, or is longer than 100. | Use 1-100 visible ASCII characters. |
| 403 | HTTPS_REQUIRED |
The request used plain http. | Use https://. |
| 404 | NOT_FOUND |
Wrong URL. | Use call.php, status.php or templates.php. |
| 405 | METHOD_NOT_ALLOWED |
Wrong HTTP method (details.allowed shows the right one). | POST for call.php, GET for status.php and templates.php. |
| 413 | PAYLOAD_TOO_LARGE |
The body is larger than 8 KB. | Send only the fields this API accepts. |
| 415 | INVALID_CONTENT_TYPE |
Content-Type is not application/json. | Add the header Content-Type: application/json. |
| API key and account | |||
| 401 | AUTH_MISSING |
No API key in the Authorization header. | Send Authorization: YOUR_API_KEY. |
| 401 | AUTH_INVALID |
Wrong API key. | Copy the key again from Profile. Too many wrong keys = 429 for 30 minutes. |
| 401 | API_KEY_REVOKED |
Your API key must be changed. | Make a new key in the dashboard. |
| 403 | ACCOUNT_DISABLED |
The account is blocked. | Contact SENDPK support. |
| 403 | ACCOUNT_NOT_VERIFIED |
The account is not verified yet. | Verify your account on sendpk.com. |
| 403 | IP_NOT_ALLOWED |
Your server IP is not whitelisted (details.ip = the IP we saw). | Add that IP in Profile (IPv6 too, if your server uses it). |
| 403 | SERVICE_DISABLED |
Voice calls are not switched on for your account. | Contact SENDPK support. |
| 403 | FORBIDDEN |
Access not allowed. | Contact SENDPK support with the request_id. |
| Fields | |||
| 422 | UNKNOWN_FIELD |
A field this API does not accept (details.field, details.allowed). | Remove it. Allowed: number, template_id, dtmf, notification_callback (+ audio_url for free form). |
| 422 | AUDIO_NOT_ALLOWED |
Text-to-speech or audio fields are never accepted; audio_url only on free-form accounts. | Use template_id of an approved recording. |
| 422 | INVALID_AUDIO_URL |
audio_url is not an allowed link, or the file is not reachable / not audio / too big / too long (details.reason). | See "Own Audio Link" for the rules. |
| 422 | CONFLICTING_FIELDS |
Both template_id and audio_url were sent. | Send one of them. |
| 422 | MISSING_NUMBER |
number is missing. | Add number. |
| 422 | INVALID_NUMBER |
Not a valid phone number (or the network refused it). | Use the form +923001234567. |
| 422 | NUMBER_NOT_MOBILE |
Not a mobile number (details.number_type). | Only mobile numbers can be called. |
| 422 | COUNTRY_NOT_SUPPORTED |
Calls to this country are not available (details.country_iso_code). | Only Pakistani mobile numbers. |
| 422 | MISSING_TEMPLATE_ID |
template_id (or, for free form, audio_url) is missing. | Add template_id. |
| 422 | INVALID_TEMPLATE_ID |
template_id is not a positive whole number. | Send the id from templates.php, e.g. 3301118. |
| 422 | INVALID_DTMF |
Wrong dtmf settings (details.field), or recording + timeout is longer than 245 s. | See "Key Presses (DTMF)". |
| 422 | INVALID_CALLBACK_URL |
notification_callback is not allowed. | Public https URL, port 443 or 8443, max 500 characters. |
| 422 | MISSING_ID |
status.php was called without id. | Add ?id=CALL_ID. |
| 422 | INVALID_ID |
id is not a valid call id. | Use data.id from the call.php reply. |
| Recordings | |||
| 404 | TEMPLATE_NOT_FOUND |
No recording with this id on your account. | Check the id with templates.php. |
| 422 | TEMPLATE_PENDING |
The recording is still waiting for approval. | Wait for approval. |
| 422 | TEMPLATE_REJECTED |
The recording was rejected (details.reason). | Fix it and have a new one approved. |
| 422 | TEMPLATE_NOT_APPROVED |
The recording is not approved. | Contact SENDPK support. |
| 422 | TEMPLATE_INVALID |
The recording cannot be played (longer than 240 s, or a file problem). | Contact SENDPK support. |
| 503 | RECORDING_UNAVAILABLE |
The recording file cannot be reached right now. SENDPK is alerted. | Retry later. |
| Money and limits | |||
| 402 | INSUFFICIENT_BALANCE |
No single PKR package can pay the full price (details.required, details.available = your biggest package). | Top up, or use a shorter recording. |
| 402 | VOICE_PRICE_NOT_SET |
Your package has no voice price yet. | Contact SENDPK support. |
| 409 | NUMBER_BUSY |
A different call to this number is still running (details.id). | Wait up to 2 minutes (details.retry_after_seconds). |
| 409 | IDEMPOTENCY_CONFLICT |
The same Idempotency-Key was used with a different body. | Use a new key for a new call. |
| 429 | RATE_LIMIT_EXCEEDED |
Too many calls per minute, or too many wrong API keys (details.scope = account, ip or auth). | Wait details.retry_after_seconds. |
| 429 | CONCURRENT_LIMIT_REACHED |
Too many calls running at the same time. | Wait for running calls to end. |
| 429 | DAILY_LIMIT_REACHED |
Daily call limit reached. | Try again after 00:00 Pakistan time. |
| 429 | NUMBER_DAILY_LIMIT_REACHED |
Too many calls to this number today. | Try again after 00:00 Pakistan time. |
| Our side / the call network | |||
| 500 | INTERNAL_ERROR |
Our error. | Retry with the SAME Idempotency-Key. |
| 502 | PROVIDER_REJECTED |
The call network refused the call. The money is given back. | Check the number; retry later. |
| 502 | PROVIDER_INVALID_RESPONSE |
The call network sent a bad reply. | Retry with the SAME Idempotency-Key. |
| 503 | PROVIDER_UNAVAILABLE |
The call service is temporarily unavailable. | Retry later. |
| 503 | PROVIDER_BUSY |
The call service is busy. | Retry after details.retry_after_seconds. |
| 503 | SERVICE_BUSY |
Busy for a moment. | Retry after details.retry_after_seconds. |
| 503 | SERVICE_UNAVAILABLE |
Down for a moment (or voice calls are paused). | Retry later. |
| 504 | PROVIDER_TIMEOUT |
The call network did not answer in time. | Retry with the SAME Idempotency-Key. |
error_code on a call (in Status and webhooks, not an error reply)
| Code | Meaning |
|---|---|
PROVIDER_UNKNOWN | We could not confirm the call was placed (checked for 15 minutes). Free. If the network reports it later, the call is updated. |
REPORT_MISSING | No result arrived within 48 hours. Free. |
4xx or 5xx?
After a 4xx no call was made and nothing was charged. After a 5xx (or no reply), send the
request again with the same Idempotency-Key: if a call went out you get it back, never a second one.
Troubleshooting
The problems people meet most, and the fix.
| Problem | Why | Fix |
|---|---|---|
403 SERVICE_DISABLED | Voice calls are not switched on for your account. | Contact SENDPK support. |
402 INSUFFICIENT_BALANCE but I have balance | The full price must fit in one PKR package. | Compare details.required with details.available; top up one package. |
402 VOICE_PRICE_NOT_SET | Your package has no voice price yet. | Contact SENDPK support. |
422 TEMPLATE_PENDING | The recording is not reviewed yet. | Wait; check templates.php. |
422 INVALID_DTMF with a valid timeout | Recording + timeout is over 245 seconds. | Use a shorter timeout (e.g. 240 s recording = max 5 s). |
A 202 reply with pending | The call network was slow to confirm. | Do not send again. Wait for the webhook or check Status. |
409 NUMBER_BUSY | A different call to that number started less than 2 minutes ago and is still running. | Wait, or check details.id. |
| No webhook arrives | The URL breaks a rule, or your server did not answer 2xx within 5 s. | Check webhook.status and last_http_code in Status. |
| Signature never matches | The body was re-encoded, or an old API key is used. | Use the raw body and your current key. Test with the fixed values. |
dtmf.digits is null on a final call | The keys could not be read for this call. | Treat it as “no answer given”. |
403 IP_NOT_ALLOWED | IP restriction is on and this IP is not on your list. | Add details.ip in Profile (IPv6 too). |
422 AUDIO_NOT_ALLOWED | A text / audio field was sent, or audio_url without free form. | Use template_id. |
Frequently Asked Questions
Best Practices
A short checklist. Follow it and most problems never happen.
- Keep your API key on your server, in an environment variable.
- Send an Idempotency-Key with every call, and retry only with the same key.
- Never re-send a
202 pendingcall with a new key. - Check the webhook signature on every webhook, and skip repeated
event_ids. - Act on
status(completed/failed), not only onevent. - Keep recordings short: every started 30 seconds is a pulse.
- Say the choices clearly in the recording (“press 1 to confirm”) and set
valid_digits. - Respect
Retry-Afteron every 429 and 503. - Save
meta.request_idfor support. - Call people at sensible hours, and only people who agreed to hear from you.
Support
Need help integrating?
We can switch voice calls on, set your price, add and review your recordings, raise your limits, or find out
why a call did not go through. Keep your request_id or call id ready.
Looking for SMS instead? See the SMS API documentation. To verify numbers with a missed call, see Missed Call Verification.