Missed Call Verification API
Verify mobile numbers with a simple missed call — no SMS OTP required.
Missed Call Verification lets you verify a user's mobile number by placing a short missed call. SENDPK calls the user's number for around 2–3 seconds. The last 4 digits of the incoming caller number become the verification PIN.
The user's app reads or asks for those last 4 digits and sends them back to SENDPK. If they match, the number is verified.
API Base URL
All API responses are returned as JSON, including errors. All timestamps are Unix timestamps in seconds.
Quick Start
Six steps from nothing to a verified number.
Get your SENDPK API key from your dashboard.
Send the user's mobile number to missed-call-auth.php
SENDPK calls the number for 2–3 seconds.
Read the last 4 digits of the caller number.
Send those 4 digits to verify.php
You get "validated": true — the number is verified.
Worldwide Mobile Verification
Missed Call Verification is designed for worldwide mobile-number verification.
Coverage depends on destination and call-provider availability — if a country is not
available you simply get COUNTRY_NOT_SUPPORTED, and nothing is charged.
Almost Free FREE TO START
Starting a verification request, checking the number, and verifying the PIN are free. A charge is applied only when the call provider confirms that the call was chargeable — normally when the customer answers during the short call.
View Missed Call Verification RatesKeep your API key on your server
Call this API from your own server only — never straight from a mobile app or a browser. Anyone who gets your API key can spend your balance.
- Never inside an Android APK
- Never inside an iOS app
- Never in browser JavaScript
- Never in a public GitHub repository
How It Works
Five steps — and nothing is ever delivered to the phone.
No SMS. No OTP message. Nothing at all arrives on the phone. The code is never sent — it is the number that rings. Your user simply reads the last 4 digits from the missed call in their own call list.
- 1Your app
You ask us to call a number
Target number to verify+92 333 0189315 - 2We call
We ring that phone for 2–3 seconds
The call stops on its own. Your user does not need to answer — and should not. - 3Their phone
The number that called is the code
Incoming → missed+1 513‑613‑7973last 4 digits = the PIN - 4Your screen
They type those 4 digits into your app
7973On Android your app can read it automatically. - 5We confirm
We tell you if it matched
Verifying…Code matched+92 333 0189315 verified
Because the PIN never travels anywhere, there is no message to intercept, no delivery to wait for and no SMS cost. It works the same on any network, in any country we can call.
Two small rules that save you trouble
Wait 30–40 seconds for the call to arrive before giving up. If nothing arrives, stop the
request with "pin": "HANGUP" (or cancel.php) and offer another method, such as SMS OTP.
Call the API from your server, never from the mobile app or browser. Your API key must remain secret.
Authentication
One header. The same SENDPK API key your account already uses for 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 — ignored, you get AUTH_MISSING. |
| Username / password login | NO — not supported at all. |
HTTPS is required
Always call the API with https://. A request over plain http:// is refused with
403 HTTPS_REQUIRED.
Why: HTTPS protects your API key and request data while they travel between your server and SENDPK. Over plain http anyone on the way could read your key.
If IP restriction is on for your account
- Your server's IP address must be whitelisted in your SENDPK dashboard (Profile).
- If your server also connects over IPv6, whitelist that address too.
- Otherwise you get
403 IP_NOT_ALLOWED, anddetails.ipshows exactly the IP we saw — copy that value into your whitelist.
Missed Call Verification is switched on for every SENDPK account, so there is nothing to request before you
start. If an account is ever turned off (for example after abuse), every start request answers
403 SERVICE_DISABLED — contact our support team to have it enabled again.
Base URL & Endpoints
Four endpoints. That is the whole API.
| Method | Endpoint | What it does |
|---|---|---|
| POST | /missed-call-auth.php |
Start a missed call verification. |
| POST | /verify.php |
Verify the 4-digit PIN (or stop the call with HANGUP). |
| GET | /status.php?id=REQUEST_ID |
Check the verification status. |
| POST | /cancel.php |
Cancel / stop a running verification. |
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-0a1b2c3d4e5f"
}
}ERROR
{
"success": false,
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Calls per minute limit exceeded.",
"details": { "scope": "account", "limit": 2, "retry_after_seconds": 41 }
},
"meta": {
"request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f"
}
}| Field | Meaning |
|---|---|
success | Tells you if the request worked: true or false. Check this first. |
data | The result, when success is true. |
error | The problem, when success is false: a code, a message and extra details. |
data.id | The missed call request ID. Use it for Verify, Status and Cancel. |
meta.request_id | Identifies this one HTTP request (also sent back as the X-Request-Id header). It is not the call ID. |
Keep request_id when troubleshooting and provide it to SENDPK Support.
Every request is written to our log with that ID, so support can find your exact request at once.
When details.retry_after_seconds is present, a Retry-After header is sent too.
Start Verification 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. See Idempotency. |
Body fields
| Field | Description |
|---|---|
number required |
The mobile number. E.164 is recommended: +923330189315.
Also accepted: 923330189315, 00923330189315, 03330189315. |
platform optional |
One of ios, android, web, desktop. |
notification_callback optional |
Your webhook URL for this request: a public https address on port 443 or 8443, max 500 characters.
If you leave it out, the default URL saved on your account is used. See Webhooks. |
end_user_ip optional |
The public IP address of your app user. Helps fraud protection. Private addresses (10.x, 192.168.x…) are refused. |
Example request
curl https://sendpk.com/apps/call/missed-call-auth.php \
-H "Content-Type: application/json" \
-H "Authorization: API_KEY" \
-H "Idempotency-Key: signup-7f3a9c2e" \
-d '{
"number": "+923330189315",
"platform": "android",
"notification_callback": "https://example.com/webhooks/missed-call"
}'Example response HTTP 200
{
"success": true,
"data": {
"id": "01a0b338-57cf-766b-9a04-3a88decd1978",
"status": "pending",
"pin_hash": "b6e2efd8ffb991dd3ea069b7b9286d5e31f97b36",
"cli_prefix": "44125",
"validation_info": {
"country_code": 92,
"country_iso_code": "PK",
"carrier": "Jazz",
"is_mobile": true,
"e164_format": "+923001654321",
"formatting": "+92 300 1654321"
},
"expires_at": 1789716809,
"daily_limit": 100,
"daily_verified": 1,
"reused": false
},
"meta": { "request_id": "01a0b338-56fa-712e-8d98-1035ab66bb54" }
}| Field | Meaning |
|---|---|
id | Request ID. Use it in Verify, Status and Cancel. |
status | pending right after the start. |
pin_hash | From the call provider: SHA-1 of the last 3 digits of the calling number. Server-side use only — see the warning below. |
cli_prefix | The start of the number that will call. Only present if the provider sends it. Use it to spot the right incoming call in your app. |
validation_info | What we know about the number: country code, ISO code, carrier, mobile or not, and two tidy formats. |
validation_info.carrier | The network the number range was originally given to. A ported number may be on another network now. |
expires_at | The PIN can be verified until this time (60 minutes). |
daily_limit / daily_verified | Your daily limit, and how many numbers you verified today (Pakistan time). |
reused | true when the same number was requested again within 60 seconds: the running request is returned and no new call is made. |
Never send pin_hash to the app or browser
pin_hash is the SHA-1 of the last 3 digits of the calling number. Anyone can turn that
back into those 3 digits, and then only 10 guesses remain for the PIN. Keep it on your server.
To spot the right incoming call inside your app, use cli_prefix instead.
No money is taken when a call starts — see Billing.
Verify PIN POST
What is the PIN?
The PIN is the last 4 digits of the number that called the user.
+9230001230711"0711"
Send the PIN as a string: "0711", never the number 711.
Leading zeros matter and JSON drops them from plain numbers.
Body fields
| Field | Description |
|---|---|
id required | The id from the start reply. |
pin required | The last 4 digits of the calling number, as a string. Or "HANGUP" to stop the call. |
use_server_hangup optional | Default true: the call is ended from our side. Recommended, because some carriers charge when the phone rejects the call. |
Example request
curl https://sendpk.com/apps/call/verify.php \
-H "Content-Type: application/json" \
-H "Authorization: API_KEY" \
-d '{
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"pin": "9711",
"use_server_hangup": true
}'Correct PIN HTTP 200
{
"success": true,
"data": {
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"number": "+923330189315",
"status": "verified",
"validated": true,
"validation_date": 1770000042,
"charged_amount": 0.0,
"currency": "PKR",
"billing_status": "pending"
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e61" }
}"validated": true means the mobile number has been successfully verified.
That is the only field you need to decide "this user owns this number".
Wrong PIN HTTP 422
{
"success": false,
"error": {
"code": "PIN_INVALID",
"message": "The verification PIN is invalid.",
"details": { "attempts_remaining": 2 }
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e62" }
}The PIN is wrong. details.attempts_remaining tells you how many tries are left —
show that number to your user.
| Rule | What happens |
|---|---|
| PIN tries | 3 per request. After the third wrong PIN the request is closed with 429 PIN_ATTEMPTS_EXCEEDED. |
| Verify again after success | You get the same reply back. It is not counted or charged again. |
"pin": "HANGUP" | Stops the call and closes the request (status: "cancelled"). It does not use a PIN try. |
Stop the call instead of verifying
{
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"pin": "HANGUP"
}Check Status GET
Example request
curl "https://sendpk.com/apps/call/status.php?id=0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f" \
-H "Authorization: API_KEY"Example response HTTP 200
{
"success": true,
"data": {
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"number": "+923330189315",
"status": "verified",
"validated": true,
"validation_date": 1770000042,
"created_at": 1770000000,
"expires_at": 1770003600,
"attempts_used": 1,
"attempts_remaining": 0,
"charged_amount": 67.28,
"currency": "PKR",
"billing_status": "charged",
"billed_seconds": 60,
"error_code": null,
"validation_info": {
"country_code": 92,
"country_iso_code": "PK",
"carrier": "Ufone",
"is_mobile": true,
"e164_format": "+923330189315",
"formatting": "+92 333 0189315"
},
"webhook": { "status": "sent", "last_event": "billing.charged", "delivered": 2, "last_http_code": 200 }
},
"meta": { "request_id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e63" }
}
charged_amount above is only an example figure. Your real amount depends on your account and the
destination — see current rates.
status values
| status | Meaning |
|---|---|
pending | Call placed, waiting for verification. |
verified | PIN is correct. Final. |
failed | Verification failed — too many wrong PINs, or the call could not be placed. Final. See error_code. |
expired | The PIN was not verified within 60 minutes. Final. |
cancelled | Verification was cancelled / stopped. Final. |
Important fields
| Field | Meaning |
|---|---|
status | Where the request is now (table above). |
validated | true once the PIN was correct. |
created_at | When the request started (Unix seconds). |
expires_at | Last moment the PIN can still be verified (60 minutes after the start). |
attempts_used / attempts_remaining | PIN tries used, and how many are left out of 3. |
charged_amount | How much was taken from your balance. Stays 0 until the provider confirms a charge. |
currency | Always PKR. |
billing_status | pending, charged, not_charged or short — see Billing. |
billed_seconds | Seconds the provider billed, when it tells us. |
error_code | Why a failed request failed. null when there is no problem. |
validation_info | Country, carrier and tidy formats of the number. |
Cancel Verification POST
Cancel is simply used to stop a running verification — for example when no call reached the user, or the user closed your app.
Body
{
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f"
}Example request
curl https://sendpk.com/apps/call/cancel.php \
-H "Content-Type: application/json" \
-H "Authorization: API_KEY" \
-d '{"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f"}'
verify.php with "pin": "HANGUP" does exactly the same thing.
Use whichever fits your code better. A request that is already verified answers
409 REQUEST_ALREADY_VERIFIED; one that is already closed answers 409 REQUEST_ALREADY_TERMINATED.
Billing
You are not charged just because you start a verification.
Starting a call FREE
Placing the missed call costs you nothing.
Checking the number FREE
Status checks cost nothing, as often as you need them.
Verifying the PIN FREE
Checking the 4 digits costs nothing.
So when am I charged?
You are charged only if the call provider confirms that the call was chargeable. Normally this happens when the person answers the short 2–3 second call.
Your actual rate depends on your SENDPK account and the destination, so an expensive country costs more than a call inside Pakistan.
A call is charged at most once. The amount really taken is in
charged_amount.
Two things that can trigger a charge you did not expect
The phone rejects the call instead of letting it ring out. Some carriers treat a
rejected call as answered in the background and play a message such as "the number you
are calling is busy" — and then charge the caller for it. Keep
use_server_hangup: true (the default on Verify) so SENDPK ends the
call from its own side, and make sure your app never rejects or hangs up the call itself.
The same number is tested too often. Mobile operators run anti-spam and fraud protection that can react to many calls that never properly connect to one number, and may then treat a later attempt as answered even though it was not. While you are integrating, avoid calling the same test number repeatedly in a short time — space your tests out, or use a few different numbers.
billing_status values
| billing_status | Meaning | charged_amount |
|---|---|---|
pending | The provider has not returned the final billing result yet (up to about 10 minutes after the call). | 0 |
charged | The call was charged. The amount was taken from your balance. | the amount taken |
not_charged | No charge was made. | 0 |
short | The call was charged, but your balance was lower than the full amount, so the remaining balance was used. | less than the amount |
Do not treat charged_amount as final while billing_status is pending.
Wait for charged, not_charged or short — or let the
billing.charged webhook tell you.
Safe Retries with Idempotency-Key
Sometimes the internet is slow and you may not know whether your request reached SENDPK. Use the same Idempotency-Key when retrying.
| What you send | What happens |
|---|---|
| Same key + same body | You get the same request back. No second call. No second charge. |
| Same key + different body | 409 IDEMPOTENCY_CONFLICT. Use a new key for a new request. |
| The first request failed | The retry returns the same error, and no new call is made. |
A key is remembered for 24 hours. Make it unique per verification attempt — for example
signup- plus your own order or user ID.
Limits
These are the default limits per account (all your requests together). SENDPK can change them for you.
| Limit | Default | Error when you pass it |
|---|---|---|
| New calls per minute (any 60 seconds) | 2 | 429 RATE_LIMIT_EXCEEDED (details.scope = "account") |
| Calls running at the same time | 2 | 429 CONCURRENT_LIMIT_REACHED |
| Verified numbers per day | 10 – 200, set by your package read yours from daily_limit | 429 DAILY_LIMIT_REACHED |
| New calls per IP address per minute | 30 | 429 RATE_LIMIT_EXCEEDED (details.scope = "ip") |
| Calls to the same number per day | 10 | 429 NUMBER_DAILY_LIMIT_REACHED |
| Unverified calls per day | daily verified limit × 3 (at least 30) | 429 DAILY_ATTEMPTS_LIMIT_REACHED |
| Failed API key attempts | 5 per 30 min per IP + key, 20 per 10 min per IP | 429 RATE_LIMIT_EXCEEDED (details.scope = "auth") |
Good to know
reused: true) and no new call is made.details.retry_after_seconds and a Retry-After header. Wait that long, then try again.Webhooks
Webhooks let SENDPK tell your server when something happens, so you do not always have to keep checking Status.
Set notification_callback in the start request, or ask SENDPK to save a default webhook URL on your
account. SENDPK then sends a POST with a JSON body when something happens.
Events
| Event | When it is sent |
|---|---|
validation.completed | The number was verified. |
validation.failed | The request was closed — for example too many wrong PINs. |
validation.expired | The PIN was not verified within 60 minutes. |
validation.cancelled | The call was stopped with HANGUP / cancel. |
billing.charged | The call was charged (billing_status is charged or short). |
Your webhook URL must be
| Rule | Detail |
|---|---|
Public https | Plain http is refused. |
| Port 443 or 8443 | No other port is allowed. |
| Max 500 characters | Longer URLs are refused. |
No localhost | We must be able to reach it from the internet. |
| No private / internal addresses | 10.x, 192.168.x, 127.x and similar are refused. |
| No SENDPK domains | A webhook may not point back at SENDPK itself. |
| Must pass URL validation | No user:password in the URL, and no \ or @ characters. The address is checked again right before every send. |
A URL that breaks any of these rules is refused with 422 INVALID_CALLBACK_URL.
Headers we send
Content-Type: application/json
User-Agent: SENDPK-Webhook/1.0
X-SENDPK-Event: validation.completed
X-SENDPK-Event-Id: 5b1d0e8a-2f3c-5a4b-9c8d-7e6f5a4b3c2d
X-SENDPK-Signature: sha256=<hex HMAC-SHA256 of the raw body>Body we send
{
"event": "validation.completed",
"event_id": "5b1d0e8a-2f3c-5a4b-9c8d-7e6f5a4b3c2d",
"id": "0192f4c3-7a1b-7c2d-9e3f-0a1b2c3d4e5f",
"number": "+923330189315",
"status": "verified",
"validated": true,
"validation_date": 1770000042,
"charged_amount": 0.0,
"currency": "PKR",
"billing_status": "pending",
"timestamp": 1770000043
}Delivery and retries
2xx status within 5 seconds.408, 429 or any 5xx — first after 1 minute, then after 5 minutes (3 tries in total).4xx replies and redirects.event_id to ignore repeats — it stays the same for the same event.validation.*, then billing.charged.Webhook Security
The webhook signature proves that the webhook came from SENDPK and has not been changed.
We sign the exact raw body with HMAC-SHA256, using your API key as the secret, and
send the result in the X-SENDPK-Signature header. Your server does the same calculation and compares.
If the two do not match, the request is not from us — throw it away.
PHP
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, 'YOUR_API_KEY');
if (!hash_equals($expected, $_SERVER['HTTP_X_SENDPK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);Node.js
const crypto = require('crypto');
// rawBody must be the exact bytes received (not re-encoded JSON)
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.SENDPK_API_KEY).update(rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.get('X-SENDPK-Signature') || ''));Use the raw body, always
Always calculate the signature using the exact raw request body. Do not JSON-decode and re-encode it before calculating the signature — even one changed space makes a completely different signature, and your check will always fail.
If you change your API key, use the new key to check signatures from that moment on.
Android Guide
On Android your app can usually read the caller number itself, so verification can feel automatic.
What your app does
verify.php.use_server_hangup: true so SENDPK ends the call from our side.Platform-specific integration instructions for Android will be added here.
iOS Guide
On iOS the user types the 4 digits, because apps cannot read the caller number.
What your app does
verify.php.Platform-specific integration instructions for iOS will be added here.
Desktop Guide
For desktop and web apps, the rule is simple: the API call stays on your server.
What your app does
verify.php.Platform-specific integration instructions for desktop and web will be added here.
Code Examples
Ready to paste. Replace YOUR_API_KEY with your own key — and keep it on your server.
All examples are server-side
The JavaScript examples are Node.js (server) code, not browser code. Never run these in a browser or inside a mobile app: your API key would be visible to everyone.
1. Start Verification
curl https://sendpk.com/apps/call/missed-call-auth.php \
-H "Content-Type: application/json" \
-H "Authorization: YOUR_API_KEY" \
-H "Idempotency-Key: signup-7f3a9c2e" \
-d '{
"number": "+923330189315",
"platform": "android"
}'<?php
// Server-side only. Keep YOUR_API_KEY out of your app and out of git.
$ch = curl_init('https://sendpk.com/apps/call/missed-call-auth.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: YOUR_API_KEY',
'Idempotency-Key: signup-7f3a9c2e',
],
CURLOPT_POSTFIELDS => json_encode([
'number' => '+923330189315',
'platform' => 'android',
]),
]);
$reply = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!empty($reply['success'])) {
$requestId = $reply['data']['id']; // save it: you need it to verify
// Do NOT send $reply['data']['pin_hash'] to your app.
} else {
error_log('Missed call start failed: ' . $reply['error']['code']);
}// Node.js 18+ (server-side). Never run this in a browser.
const res = await fetch('https://sendpk.com/apps/call/missed-call-auth.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': process.env.SENDPK_API_KEY,
'Idempotency-Key': 'signup-7f3a9c2e',
},
body: JSON.stringify({
number: '+923330189315',
platform: 'android',
}),
});
const reply = await res.json();
if (reply.success) {
const requestId = reply.data.id; // save it: you need it to verify
// Do NOT send reply.data.pin_hash to your app.
} else {
console.error('Missed call start failed:', reply.error.code);
}# Server-side only (Python 3, requests).
import os, requests
reply = requests.post(
'https://sendpk.com/apps/call/missed-call-auth.php',
headers={
'Content-Type': 'application/json',
'Authorization': os.environ['SENDPK_API_KEY'],
'Idempotency-Key': 'signup-7f3a9c2e',
},
json={'number': '+923330189315', 'platform': 'android'},
timeout=20,
).json()
if reply.get('success'):
request_id = reply['data']['id'] # save it: you need it to verify
# Do NOT send reply['data']['pin_hash'] to your app.
else:
print('Missed call start failed:', reply['error']['code'])2. Verify PIN
curl https://sendpk.com/apps/call/verify.php \
-H "Content-Type: application/json" \
-H "Authorization: YOUR_API_KEY" \
-d '{
"id": "REQUEST_ID",
"pin": "0711",
"use_server_hangup": true
}'<?php
// $pin comes from your app as a STRING, e.g. "0711" — keep the leading zero.
$ch = curl_init('https://sendpk.com/apps/call/verify.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: YOUR_API_KEY',
],
CURLOPT_POSTFIELDS => json_encode([
'id' => 'REQUEST_ID',
'pin' => $pin,
'use_server_hangup' => true,
]),
]);
$reply = json_decode(curl_exec($ch), true);
curl_close($ch);
if (!empty($reply['data']['validated'])) {
// The number is verified.
} elseif (($reply['error']['code'] ?? '') === 'PIN_INVALID') {
$left = $reply['error']['details']['attempts_remaining']; // show this to the user
}// Node.js 18+ (server-side). pin is a STRING: '0711', not 711.
const res = await fetch('https://sendpk.com/apps/call/verify.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': process.env.SENDPK_API_KEY,
},
body: JSON.stringify({
id: requestId,
pin: pin,
use_server_hangup: true,
}),
});
const reply = await res.json();
if (reply.data && reply.data.validated) {
// The number is verified.
} else if (reply.error && reply.error.code === 'PIN_INVALID') {
const left = reply.error.details.attempts_remaining; // show this to the user
}# pin is a STRING: '0711', not 711.
reply = requests.post(
'https://sendpk.com/apps/call/verify.php',
headers={
'Content-Type': 'application/json',
'Authorization': os.environ['SENDPK_API_KEY'],
},
json={'id': request_id, 'pin': pin, 'use_server_hangup': True},
timeout=20,
).json()
if reply.get('data', {}).get('validated'):
pass # the number is verified
elif reply.get('error', {}).get('code') == 'PIN_INVALID':
left = reply['error']['details']['attempts_remaining'] # show this to the user3. Check Status
curl "https://sendpk.com/apps/call/status.php?id=REQUEST_ID" \
-H "Authorization: YOUR_API_KEY"<?php
$ch = curl_init('https://sendpk.com/apps/call/status.php?id=' . urlencode($requestId));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: YOUR_API_KEY'],
]);
$reply = json_decode(curl_exec($ch), true);
curl_close($ch);
$status = $reply['data']['status'] ?? null; // pending | verified | failed | expired | cancelled
$billing = $reply['data']['billing_status'] ?? null; // pending | charged | not_charged | short// Node.js 18+ (server-side).
const res = await fetch('https://sendpk.com/apps/call/status.php?id=' + encodeURIComponent(requestId), {
headers: { 'Authorization': process.env.SENDPK_API_KEY },
});
const reply = await res.json();
const status = reply.data && reply.data.status; // pending | verified | failed | expired | cancelled
const billing = reply.data && reply.data.billing_status; // pending | charged | not_charged | shortreply = requests.get(
'https://sendpk.com/apps/call/status.php',
params={'id': request_id},
headers={'Authorization': os.environ['SENDPK_API_KEY']},
timeout=20,
).json()
status = reply.get('data', {}).get('status') # pending | verified | failed | expired | cancelled
billing = reply.get('data', {}).get('billing_status') # pending | charged | not_charged | short4. Cancel
curl https://sendpk.com/apps/call/cancel.php \
-H "Content-Type: application/json" \
-H "Authorization: YOUR_API_KEY" \
-d '{"id": "REQUEST_ID"}'<?php
// Call this when no missed call arrived within 30-40 seconds.
$ch = curl_init('https://sendpk.com/apps/call/cancel.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: YOUR_API_KEY',
],
CURLOPT_POSTFIELDS => json_encode(['id' => $requestId]),
]);
$reply = json_decode(curl_exec($ch), true);
curl_close($ch);// Node.js 18+ (server-side). Call this when no missed call arrived in 30-40 s.
const res = await fetch('https://sendpk.com/apps/call/cancel.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': process.env.SENDPK_API_KEY,
},
body: JSON.stringify({ id: requestId }),
});
const reply = await res.json();# Call this when no missed call arrived within 30-40 seconds.
reply = requests.post(
'https://sendpk.com/apps/call/cancel.php',
headers={
'Content-Type': 'application/json',
'Authorization': os.environ['SENDPK_API_KEY'],
},
json={'id': request_id},
timeout=20,
).json()5. Webhook verification
<?php
// Your webhook endpoint, e.g. https://example.com/webhooks/missed-call
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, 'YOUR_API_KEY');
if (!hash_equals($expected, $_SERVER['HTTP_X_SENDPK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// The same event can arrive more than once: ignore repeats by event_id.
if (already_handled($event['event_id'])) {
http_response_code(200);
exit;
}
if ($event['event'] === 'validation.completed') {
mark_number_verified($event['number']);
}
http_response_code(200); // answer 2xx within 5 seconds// Node.js + Express (server-side).
const crypto = require('crypto');
const express = require('express');
const app = express();
// IMPORTANT: keep the RAW body — do not let a JSON parser re-encode it.
app.post('/webhooks/missed-call', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body; // Buffer with the exact bytes we sent
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.SENDPK_API_KEY).update(rawBody).digest('hex');
const given = req.get('X-SENDPK-Signature') || '';
if (expected.length !== given.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.sendStatus(401);
}
const event = JSON.parse(rawBody.toString('utf8'));
// Use event.event_id to ignore repeats.
res.sendStatus(200); // answer 2xx within 5 seconds
});# Flask (server-side).
import hashlib, hmac, os
from flask import Flask, request
app = Flask(__name__)
@app.post('/webhooks/missed-call')
def missed_call_webhook():
raw = request.get_data() # the exact bytes we sent
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', '')):
return '', 401
event = request.get_json()
# Use event['event_id'] to ignore repeats.
return '', 200 # answer 2xx within 5 secondsPostman Collection
Test the whole API without writing a single line of code.
SENDPK Missed Call Verification
Import this collection into Postman, enter your API key, and start testing.
Every endpoint is pre-built, and "Start Verification" automatically saves the returned
id into the request_id variable, so Verify, Status and Cancel work straight away.
What is inside
- 1. Authentication / Setup — check your API key works
- 2. Start Verification
- 3. Verify PIN (plus a HANGUP request)
- 4. Check Status
- 5. Cancel Verification
- 6. Webhooks / Examples — send a signed test webhook to your own server
Collection variables
base_url—https://sendpk.com/apps/callapi_key— your own API keyrequest_id— filled in for you after a startphone_number— the number to verifypin— the last 4 digits you receivedwebhook_url— your own webhook endpoint
How to import
In Postman click Import, choose the downloaded
.json file, open the collection's Variables tab, paste your
api_key, then run 1. Authentication / Setup first. Your API key is secret —
do not share the collection after you fill it in.
Error Codes
Every error reply has a code you can switch on in your program. The message is for humans; the code is for your code.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_JSON | Body is not a valid JSON object. |
| 400 | INVALID_IDEMPOTENCY_KEY | Idempotency-Key is empty, too long or has spaces. |
| 400 | BAD_REQUEST | The request could not be understood. |
| 401 | AUTH_MISSING | No API key in the Authorization header. |
| 401 | AUTH_INVALID | Wrong API key. |
| 401 | API_KEY_REVOKED | Your API key must be changed in the dashboard. |
| 402 | INSUFFICIENT_BALANCE | Your PKR balance is below the minimum needed to start a call (details.minimum_balance). |
| 403 | ACCOUNT_DISABLED | Account blocked. |
| 403 | IP_NOT_ALLOWED | Your server IP is not whitelisted. details.ip shows the IP we saw. |
| 403 | SERVICE_DISABLED | Missed call service is not switched on for your account. |
| 403 | HTTPS_REQUIRED | The request used plain http. Use https://. |
| 403 | FORBIDDEN | Access not allowed. |
| 404 | NOT_FOUND | Wrong URL. |
| 404 | REQUEST_NOT_FOUND | No request with this id on your account. |
| 405 | METHOD_NOT_ALLOWED | Wrong HTTP method (for example GET instead of POST). |
| 409 | IDEMPOTENCY_CONFLICT | Same Idempotency-Key used with a different body. |
| 409 | REQUEST_IN_PROGRESS | The request is still starting. Retry in a few seconds. |
| 409 | REQUEST_EXPIRED | The 60 minutes are over. Start a new request. |
| 409 | REQUEST_ALREADY_VERIFIED | Cancel was called on a request that is already verified. |
| 409 | REQUEST_ALREADY_TERMINATED | The request is already closed. |
| 413 | PAYLOAD_TOO_LARGE | Body larger than 8 KB. |
| 415 | INVALID_CONTENT_TYPE | Content-Type must be application/json. |
| 422 | MISSING_NUMBER | number is missing. |
| 422 | INVALID_NUMBER | Not a valid phone number. |
| 422 | NUMBER_NOT_MOBILE | Not a mobile number. |
| 422 | COUNTRY_NOT_SUPPORTED | Missed call is not available for this country. |
| 422 | NUMBER_BLOCKED | This number cannot receive missed call verification. |
| 422 | INVALID_PLATFORM | platform is not ios / android / web / desktop. |
| 422 | INVALID_CALLBACK_URL | Webhook URL is not allowed — see the rules in Webhooks. |
| 422 | INVALID_END_USER_IP | end_user_ip is not a public IP address. |
| 422 | INVALID_FIELD | A field has a wrong type (see details.field). |
| 422 | MISSING_ID / INVALID_ID | id missing, or not a valid request id. |
| 422 | PIN_REQUIRED / INVALID_PIN_FORMAT | pin missing, or not 4 digits / HANGUP. |
| 422 | PIN_INVALID | Wrong PIN. details.attempts_remaining shows the tries left. |
| 429 | PIN_ATTEMPTS_EXCEEDED | 3 wrong PINs. The request is closed. |
| 429 | RATE_LIMIT_EXCEEDED | Too many calls per minute (see details.scope). |
| 429 | CONCURRENT_LIMIT_REACHED | Too many calls running at the same time. |
| 429 | DAILY_LIMIT_REACHED | Daily verified limit reached. |
| 429 | NUMBER_DAILY_LIMIT_REACHED | Too many calls to this number today. |
| 429 | DAILY_ATTEMPTS_LIMIT_REACHED | Too many unverified calls today. |
| 429 | NUMBER_TEMPORARILY_BLOCKED | Too many attempts for this number. Try later. |
| 500 | INTERNAL_ERROR / BILLING_ERROR | Our error. Retry later. |
| 502 | PROVIDER_REJECTED | The call provider refused the request. |
| 502 | PROVIDER_INVALID_RESPONSE | The call provider sent a bad reply. |
| 503 | PROVIDER_UNAVAILABLE | Call service temporarily unavailable. |
| 503 | SERVICE_BUSY / SERVICE_UNAVAILABLE | Busy or down for a moment. Retry after retry_after_seconds. |
| 504 | PROVIDER_TIMEOUT | The provider did not answer in time. The call may still ring: check Status, or retry with the same Idempotency-Key. |
| 504 | PROVIDER_UNKNOWN | The call could not be confirmed. Start a new request. |
Frequently Asked Questions
Best Practices
A short checklist. Follow it and most problems never happen.
- Always use HTTPS.
- Keep your API key on your server.
- Never put the API key inside Android, iOS or browser code.
- Use E.164 numbers where possible:
+923330189315. - Treat the PIN as a string:
"0711", not711. - Use an Idempotency-Key for safe retries.
- Use
use_server_hangup: truewhere appropriate. - Do not expose
pin_hashto the client. - Verify webhook signatures on every webhook.
- Use
event_idto prevent duplicate processing. - Save
request_idfor troubleshooting. - Respect the rate limits and the
Retry-Afterheader. - Check Status when the provider response is delayed.
Support
Need help integrating?
Our team can help you switch the service on for your account, whitelist your server IP, or
work out why a verification did not go through. Keep your request_id ready — it lets us find
your exact request straight away.
Looking for our SMS & WhatsApp API instead? See the SMS API documentation.