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 read the caller number itself, so verification is automatic: the phone rings, the app ends the call, fills in the PIN and verifies it. The user types nothing.
What your app does
verify.php.use_server_hangup: true in the verify, so SENDPK also ends the call if the phone could not.Try it: Android demo app
A small, ready-made app that shows the whole flow. You type a number and your phone starts to ring. The app ends the call at once, takes the last 4 digits of the caller number and verifies them — you do not type anything.
Android 8.0 or newer, with a SIM card. The source code is Kotlin + Jetpack Compose and opens in Android Studio. The ready-to-install APK is coming soon; until then, build it from the source in a few minutes.
Step by step: from zero to "Number verified"
Follow the steps in order. Each step is one small thing. The orange boxes and arrows show you where to look.
- An Android phone with a SIM card
- A website that opens with
https:// - Your SENDPK API key
- A computer with Android Studio (free)
Download the server file
Click Download Server File (PHP) and unzip it.
Inside is one file: sendpk-missed-call-bridge.php. This file keeps your API key safe on
your server, so it is never inside the app.
Put your API key in the file
Open the file in any text editor (Notepad is fine). Near the top, change the orange line to your own key. You find your key in the SENDPK Dashboard → Profile → API Key.
$BRIDGE_CONFIG = [ // Your SENDPK API key. Keep it secret. 'api_key' => 'PASTE_YOUR_SENDPK_API_KEY_HERE', 1 Paste your API key between the quotes ...
Upload it and check it
Upload the file to your website, like any other PHP file. Then open it in your browser with
?action=ping at the end. If you see "ready": true, your server is ready.
{ "ok": true, "service": "sendpk-missed-call-bridge", "version": "1.0", "ready": true, 1 true = it works. false = read the "problems" list below it "problems": [] }
The file has built-in limits, so strangers cannot spend your balance: 5 calls per number per day and 10 new
verifications per visitor per hour. Every country works; to allow only some, list their codes in
allowed_country_codes. If IP restriction is on for your SENDPK account, whitelist your server's IP
addresses (IPv4 and IPv6 — the ping shows the exact one SENDPK saw).
Put the app on your phone
Download the Source Code (ZIP) and unzip it.
In Android Studio click File → Open and choose the android folder. Wait until
it stops loading. Connect your phone with a USB cable and press the green Run ▶ button.
Tell the app where your server is
1 Type the address of the file you uploaded in step 3 (without ?action=ping).
2 Tap Test connection.
Wait for the green words
First it says Checking.... After a second it turns green: Connected. Your server is ready. Green means the app can talk to your server.
Type your mobile number and tap the button
1 Type the number with its country code, like +923001234567.
2 Tap Verify with a missed call. The first time, Android asks for
permissions: tap Allow each time. The app needs them to see the caller number and to end the call.
Your phone rings — do not answer
Just wait. 1 The countdown shows the app is waiting for the call. When the phone rings, the app ends the call at once. 2 Then it puts the last 4 digits into the PIN box by itself.
Done! Your number is verified
1 The green box says Number verified. Look at the list under it to see how it worked: 2 the call came from a number ending in 0237, and 3 those same 4 digits became the PIN. That is the whole trick.
How the app keeps your API key safe
Anyone can open an APK file and read what is inside — even hidden or encrypted text. So the demo app has no API key at all. It talks only to one small PHP file on your server, and that file talks to SENDPK.
sendpk-missed-call-bridge.php keeps your API key and calls the SENDPK API.The app only ever gets these small replies. The SENDPK request id travels inside a sealed
ticket that the app cannot read or change, and pin_hash never leaves your server.
// start
{ "ok": true, "ticket": "Qm9x...sealed", "cli_prefix": "12679", "number": "+923001234567", "wait_seconds": 40 }
// verify
{ "ok": true, "verified": true, "number": "+923001234567" }The important code
Three permissions: two let Android include the caller number, the third lets the app end the call:
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />Read the number while the phone rings:
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.getStringExtra(TelephonyManager.EXTRA_STATE) != TelephonyManager.EXTRA_STATE_RINGING) return
val caller = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) ?: return
onIncomingNumber(caller) // e.g. "+12679000711"
}
}
ContextCompat.registerReceiver(context, receiver,
IntentFilter(TelephonyManager.ACTION_PHONE_STATE_CHANGED), ContextCompat.RECEIVER_EXPORTED)Use only the right call: end it at once, then verify its last 4 digits:
fun onIncomingNumber(caller: String) {
val digits = caller.filter { it.isDigit() }.removePrefix("00")
if (!digits.startsWith(cliPrefix)) return // a friend's call: ignore it
telecomManager.endCall() // 1. end the call now (Android 9+, ANSWER_PHONE_CALLS)
val pin = digits.takeLast(4) // "+12679000711" -> "0711"
verifyOnMyServer(ticket, pin) // 2. your server calls SENDPK verify.php
}
The demo also reads the call log every 1.5 seconds as a backup, because some phones leave the number out while it rings.
See CallWatcher.kt, CallerMatch.kt and VerifyViewModel.kt in the ZIP.
Good to know
If the user says no to the permissions, show a box to type the last 4 digits from the call history — exactly like on iOS. The demo app does this for you.
Ending the call on the phone. The demo ends the call the moment it rings, and only then verifies.
On a few networks, mostly outside Pakistan, a call ended on the phone is sent to voicemail, and a voicemail answer
can be billed. If you see charges, set HANG_UP_ON_PHONE = false in VerifyViewModel.kt:
SENDPK then ends the call right after the verify (use_server_hangup: true).
Publishing on Google Play? Google allows the Call log permission only for some kinds of apps. Check Google Play's SMS and Call Log permissions policy before you publish. The typed-PIN option always works without it.
Verification of Website / iOS
A website or an iPhone app cannot see who is calling. So the user reads the last 4 digits of the number that called and types them. It is still very simple.
What your page or app does
verify.php. Wrong digits? The user can try again (3 tries).Try it: website demo
A ready-made page in two files: index.html (what your user sees) and
sendpk-missed-call-bridge.php (your API key and all settings: rate limits, countries and more).
It works on any website with PHP. iPhone apps use the same PHP file.
Step by step: from zero to "Number verified"
Follow the steps in order. The orange boxes and arrows show you where to look.
- A website that opens with
https://and runs PHP - Your SENDPK API key
- A phone to test with
Download the website demo
Click Download Website Demo (ZIP) and unzip it.
Inside are index.html, sendpk-missed-call-bridge.php and a short README.md.
Put your API key in the PHP file
Open sendpk-missed-call-bridge.php in any text editor. All settings are at the top.
1 Paste your key (from the SENDPK Dashboard → Profile → API Key).
2 The rate limits stop strangers from spending your balance — change them only if you need to.
$BRIDGE_CONFIG = [ // Your SENDPK API key. Keep it secret. 'api_key' => 'PASTE_YOUR_SENDPK_API_KEY_HERE', 1 Paste your API key between the quotes ... // Abuse limits: [how many, in how many seconds]. 'limit_start_per_number' => [5, 86400], 'limit_start_per_ip' => [10, 3600], 2 Rate limits: 5 calls per number a day, 10 per visitor an hour ...
Upload both files into one folder
Put the two files in the same folder on your website, for example verify.
Then open https://your-domain.com/verify/sendpk-missed-call-bridge.php?action=ping once —
"ready": true means the server side works. If not, the problems list says exactly what to fix.
IP restriction on? Add your server's IP address to the whitelist in your SENDPK profile. Many servers have an IPv4 and an IPv6 address — add both. The ping shows the exact address SENDPK saw. Five refused tries block new ones for 30 minutes, so fix this before you test.
Testing? Set 'debug' => true in the PHP file. Every error on the page then also
shows SENDPK's exact reply — for example IP_NOT_ALLOWED and the IP it saw — so you
know what to fix. Set it back to false before going live. Your API key is never shown.
public_html/ └── verify/ ├── index.html └── sendpk-missed-call-bridge.php 1 Both files in the same folder
Type the number and tap “Call me”
Open https://your-domain.com/verify/index.html. 1 Type the mobile number with its
country code. 2 Tap Call me.
Verify your mobile number
With a missed call. No SMS needed.
STEP 1 OF 2The phone rings — do not answer
A call comes in a few seconds. Do not answer: it stops by itself. Then look at the number that called — on an iPhone in Phone → Recents, on Android in the call history. 1 Its last 4 digits are the PIN.
Type the 4 digits and tap “Verify”
The page helps with an example. 1 Type the last 4 digits. 2 Tap Verify. Typed a wrong digit? The page says so, and the user can try again.
Do not answer. The call stops by itself after a few seconds.
Example: a call from +1 267 900 0711 → type 0711
Done! The number is verified
1 The page shows Number verified. On your server,
on_number_verified() in the PHP file runs at the same moment — put your own code there,
for example to save the number to the user's account.
For iPhone apps
Use the same PHP file. Your app sends the same two requests as the web page, and shows
one box for the 4 digits. No special permission is needed. Send the PIN as text ("0711"), so a
leading 0 is kept.
let server = "https://your-domain.com/verify/sendpk-missed-call-bridge.php"
func post(_ action: String, _ body: [String: String]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: server + "?action=" + action)!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
}
// 1. "Call me"
let start = try await post("start", ["number": "+923001234567", "platform": "ios"])
let ticket = start["ticket"] as? String ?? ""
// 2. The user types the last 4 digits of the caller, then taps "Verify"
let result = try await post("verify", ["ticket": ticket, "pin": "0711"])
let verified = result["verified"] as? Bool ?? falseGood to know
Never put your API key in a web page or in JavaScript. Anyone can see a page's code. The key stays in the PHP file on your server.
Only your own website can use the PHP file. Pages on other websites are refused
(allowed_origins), so they cannot start calls through it. If bots start using your page, add a
CAPTCHA in front of “Call me”.
No call after 40 seconds? The page offers “Start again”, which stops the old call and lets the user try once more. After 3 wrong PINs the user must start again too.
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.