HLR & MNP Lookup API
Find out which network a mobile number is really on — and whether the phone is switched on.
A phone number does not tell you which network it belongs to any more. People keep their number and move to another operator, so the number range and the real network drift apart. This API asks the networks directly and gives you the answer.
One endpoint does everything. A GET looks up one number and answers
immediately. A POST takes a whole list — up to 100,000 numbers in one
request — and answers with a batch_id you collect the results from.
API Endpoint
All API responses are returned as JSON, including errors.
HTTPS is required — plain http is refused.
Quick Start
Four steps from nothing to a network name.
Get your SENDPK API key from your dashboard.
Ask SENDPK support to switch the lookup service on.
Send type and number to lookup.php
Read current_network.network_name and mccmnc.
Answers Kept for 60 Days
Look the same number up again within 60 days and you get the stored answer back instantly,
marked "cached": true, with no request to the network — and at
25% of the normal price. Cleaning a list twice does not cost twice.
You Pay Full Price Only for Real Answers
A lookup that reaches the network but finds nothing — no route, a rejected number, a failed query — costs the reduced cache rate, never the full price. A request that never leaves our servers is free. Repeats of the same number inside one request are looked up once and charged once.
Keep 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
HLR or MNP — which one do I need?
Both tell you the real network. Only HLR tells you whether the phone is switched on.
type=mnp | type=hlr | |
|---|---|---|
| Tells you | The network the number is on now | The network and whether the phone is reachable |
| Current network | Yes | Yes |
| Original network | No | Yes |
reachable | No | Yes |
is_ported | No | Yes |
imsi | When the operator gives it | When the operator gives it |
| Pakistan coverage | Every network | Only networks with a live route |
| Price | Lower | About twice as much |
Pakistan: read this before choosing HLR
Live reachability is only available on the Pakistani networks our provider has a route to —
today that is Zong and Telenor. An HLR lookup on any other Pakistani
network comes back with "reachable": "no-coverage" and an empty current_network.
Those cost the reduced cache rate, not the full HLR price. If all you
need is the correct network, type=mnp works on every Pakistani network.
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 header is the only way in
The API key is read from the Authorization header and nowhere else. It is
never accepted in the URL or in the request body, because URLs end up in server access logs and browser
history.
There is also no username-and-password login on this API.
What is checked, in order
| Check | If it fails |
|---|---|
Connection is https | HTTPS_REQUIRED — 403 |
| Not too many failed keys lately | RATE_LIMIT_EXCEEDED — 429 |
| Header is present | AUTH_MISSING — 401 |
| Key is correct | AUTH_INVALID — 401 |
| Account is not blocked | ACCOUNT_DISABLED — 403 |
| Key is not marked for change | API_KEY_REVOKED — 401 |
| Server IP is allowed (if IP lock is on) | IP_NOT_ALLOWED — 403 |
| Lookup service is enabled on the account | SERVICE_DISABLED — 403 |
Repeated wrong keys from the same IP are throttled. Add your server IP in your dashboard Profile page if IP restriction is on for your account.
Base URL & Endpoint
There is one URL. The HTTP method and the parameters decide what happens.
| Method | Request | What it does |
|---|---|---|
| GET | ?type=mnp&number=... |
Look one number up. The answer is in the reply. |
| GET | ?batch_id=... |
Fetch the results of a bulk request. |
| POST | {"type":..., "numbers":[...]} |
Send a list of numbers. You get a batch_id back. |
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": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c"
}
}ERROR
{
"success": false,
"error": {
"code": "INVALID_LOOKUP_TYPE",
"message": "type must be one of: hlr, mnp.",
"details": { "allowed": ["hlr", "mnp"] }
},
"meta": {
"request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c"
}
}| 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. |
meta.request_id | Identifies this one HTTP request (also sent back as the X-Request-Id header). |
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.
Number Formats
Send numbers however you have them. They are all tidied up the same way before anything else happens.
| You send | Becomes | Note |
|---|---|---|
+923330189315 | +923330189315 | International — the safest form |
923330189315 | +923330189315 | Country code without the plus |
00923330189315 | +923330189315 | Leading 00 |
03330189315 | +923330189315 | Pakistani local form |
3330189315 | +923330189315 | Pakistani, no leading zero |
+92 333 018-9315 | +923330189315 | Spaces, dashes, dots and brackets are ignored |
This saves you money
Because all of those become one number, they share one cache entry and one charge. Sending the same number in two different formats in one bulk request is spotted and looked up once.
What is refused
- Anything that is not a real, valid phone number —
INVALID_NUMBER - Fewer than 7 or more than 15 digits
- More than 32 characters in total
- Letters, or symbols other than
+, space,-,.,(and)
In a bulk request an unusable number does not spoil the batch. It comes back in its own
place with "processing_status": "rejected", and it is never charged.
Single Lookup GET
Headers
| Header | Value |
|---|---|
Authorization required | Your SENDPK API key. |
Query parameters
| Name | Type | Description |
|---|---|---|
type required |
string | Either hlr or mnp. Anything else returns INVALID_LOOKUP_TYPE. |
number required |
string | The mobile number. See Number Formats. Max 32 characters. |
Request
curl -G 'https://sendpk.com/apps/hlr/lookup.php' \
--data-urlencode 'type=mnp' \
--data-urlencode 'number=923330189315' \
-H 'Authorization: YOUR_API_KEY'<?php
$query = http_build_query([
'type' => 'mnp', // or 'hlr'
'number' => '923330189315',
]);
$ch = curl_init('https://sendpk.com/apps/hlr/lookup.php?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: ' . getenv('SENDPK_API_KEY')],
CURLOPT_TIMEOUT => 40, // an HLR lookup talks to a real network
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$reply = json_decode((string) $body, true);
if ($status === 200 && !empty($reply['success'])) {
$data = $reply['data'];
echo 'Network : ' . ($data['current_network']['network_name'] ?? 'unknown') . PHP_EOL;
echo 'MCCMNC : ' . ($data['current_network']['mccmnc'] ?? 'unknown') . PHP_EOL;
echo 'Cost : ' . $data['cost'] . ' ' . $data['currency'] . PHP_EOL;
echo 'Cached : ' . ($data['cached'] ? 'yes' : 'no') . PHP_EOL;
} else {
// The code is for your program, the message is for a human.
echo 'Failed: ' . $reply['error']['code'] . ' - ' . $reply['error']['message'] . PHP_EOL;
}const params = new URLSearchParams({ type: 'mnp', number: '923330189315' });
const res = await fetch(`https://sendpk.com/apps/hlr/lookup.php?${params}`, {
headers: { Authorization: process.env.SENDPK_API_KEY }
});
const reply = await res.json();
if (reply.success) {
const d = reply.data;
console.log('Network:', d.current_network?.network_name);
console.log('MCCMNC :', d.current_network?.mccmnc);
console.log('Cost :', d.cost, d.currency, d.cached ? '(cached)' : '');
} else {
console.error(reply.error.code, reply.error.message);
}import os, requests
reply = requests.get(
'https://sendpk.com/apps/hlr/lookup.php',
params={'type': 'mnp', 'number': '923330189315'},
headers={'Authorization': os.environ['SENDPK_API_KEY']},
timeout=40,
).json()
if reply['success']:
d = reply['data']
print('Network:', d['current_network'].get('network_name'))
print('MCCMNC :', d['current_network'].get('mccmnc'))
print('Cost :', d['cost'], d['currency'], '(cached)' if d['cached'] else '')
else:
print('Failed:', reply['error']['code'], reply['error']['message'])200 MNP response
{
"success": true,
"data": {
"id": "a2ca5e09-d4fb-7732-bdf1-23a2405e4761",
"cost": 0.78,
"currency": "PKR",
"phone_number": "+923330189315",
"number_type": "mobile",
"is_disposable": false,
"timezone": "Asia/Karachi",
"format": {
"e164": "+923330189315",
"international": "+92 333 0189315",
"national": "0333 0189315",
"rfc3966": "tel:+92-333-0189315"
},
"processing_status": "completed",
"imsi": "410030000000000",
"current_network": {
"country_iso2": "PK",
"country_prefix": 92,
"country_name": "Pakistan",
"mccmnc": "41003",
"mcc": "410",
"mnc": "03",
"area": "PK",
"network_name": "Ufone"
},
"cached": false,
"original_msisdn": "923330189315"
},
"meta": { "request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c" }
}200 HLR response
Everything above, plus is_ported, reachable and original_network.
{
"success": true,
"data": {
"id": "a2ca5e09-fbb7-752a-85fa-d1cf8d3909f3",
"cost": 1.56,
"currency": "PKR",
"phone_number": "+923001234567",
"number_type": "mobile",
"is_disposable": false,
"timezone": "Asia/Karachi",
"format": {
"e164": "+923001234567",
"international": "+92 300 1234567",
"national": "0300 1234567",
"rfc3966": "tel:+92-300-1234567"
},
"is_ported": true,
"reachable": "connected",
"processing_status": "completed",
"imsi": "410040000000000",
"original_network": {
"country_iso2": "PK",
"country_prefix": 92,
"country_name": "Pakistan",
"mccmnc": "41001",
"mcc": "410",
"mnc": "01",
"area": "PK",
"network_name": "Jazz"
},
"current_network": {
"country_iso2": "PK",
"country_prefix": 92,
"country_name": "Pakistan",
"mccmnc": "41004",
"mcc": "410",
"mnc": "04",
"area": "PK",
"network_name": "Zong"
},
"cached": false,
"original_msisdn": "923001234567"
},
"meta": { "request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c" }
}
In that example original_network is Jazz but current_network is Zong, and
is_ported is true. The number was issued by Jazz and has since moved to Zong.
Always route on current_network.
200 A cached answer
Two extra fields. The rest is exactly the answer we stored the first time, including the original id.
{
"success": true,
"data": {
"id": "a2ca5e09-d4fb-7732-bdf1-23a2405e4761",
"cost": 0.195,
"currency": "PKR",
"phone_number": "+923330189315",
"...": "every other field, unchanged",
"cached": true,
"cache_expires_at": "2026-11-19T16:06:40+05:00",
"original_msisdn": "+923330189315"
},
"meta": { "request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c" }
}200 No route to that network
An HLR lookup on a network with no live route. Charged at the reduced cache rate,
not the full price — cost always tells you the real figure.
{
"success": true,
"data": {
"id": "a2ca5e09-fbb7-752a-85fa-d1cf8d3909f3",
"cost": 0.39,
"currency": "PKR",
"phone_number": "+923330189315",
"number_type": "mobile",
"is_ported": null,
"reachable": "no-coverage",
"processing_status": "completed",
"imsi": null,
"current_network": {},
"original_network": {
"country_iso2": "PK",
"country_prefix": 92,
"country_name": "Pakistan",
"mccmnc": null, "mcc": null, "mnc": null,
"area": "PK",
"network_name": "Ufone"
},
"cached": false,
"original_msisdn": "923330189315"
},
"meta": { "request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c" }
} Check reachable, not just the HTTP status
A "no route" answer is still HTTP 200 with success: true, because the
request itself worked. Read reachable and current_network to know whether you
actually learned anything — and note it is charged at the reduced cache rate, shown in
cost.
Bulk Lookup POST
Send up to 100,000 numbers in one request. We write them down and answer at once with a
batch_id — the request does not wait for the networks. Behind the scenes the numbers go
out in chunks of 500 and the answers arrive over the next few minutes.
Headers
| Header | Value |
|---|---|
Authorization required | Your SENDPK API key. |
Content-Type required | application/json — anything else is INVALID_CONTENT_TYPE. |
Idempotency-Key optional | 1–100 visible ASCII characters. See Idempotency. |
Body parameters
| Name | Type | Description |
|---|---|---|
type required |
string | Either hlr or mnp. One type per request. |
numbers required |
array | A plain JSON array of numbers. Must not be empty. Max 100,000 entries. Strings are expected; plain JSON numbers are accepted too. |
notification_callback optional |
string | Where to POST the finished batch. Must be a public https URL, max 500 characters. Give it in every request — there is no account-wide default. See Webhooks. |
Request
curl -X POST 'https://sendpk.com/apps/hlr/lookup.php' \
-H 'Authorization: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: my-job-2026-09-20-01' \
-d '{
"type": "mnp",
"numbers": ["923001234567", "923111234567", "923331234567"],
"notification_callback": "https://example.com/webhooks/lookup"
}'<?php
$payload = json_encode([
'type' => 'mnp', // or 'hlr'
'numbers' => $yourNumbers, // up to 100000
'notification_callback' => 'https://example.com/webhooks/lookup', // optional
]);
$ch = curl_init('https://sendpk.com/apps/hlr/lookup.php');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: ' . getenv('SENDPK_API_KEY'),
'Content-Type: application/json',
// Send the SAME key if you ever retry, so nothing is charged twice.
'Idempotency-Key: my-job-2026-09-20-01',
],
CURLOPT_TIMEOUT => 60,
]);
$reply = json_decode((string) curl_exec($ch), true);
curl_close($ch);
if (empty($reply['success'])) {
exit('Rejected: ' . $reply['error']['code'] . ' - ' . $reply['error']['message'] . PHP_EOL);
}
$batch = $reply['data'];
echo 'Batch : ' . $batch['batch_id'] . PHP_EOL;
echo 'Sent : ' . $batch['total_count'] . PHP_EOL;
echo 'Already known: ' . $batch['cached_count'] . PHP_EOL;
echo 'Repeats : ' . $batch['duplicate_count'] . PHP_EOL;
echo 'Unusable : ' . $batch['rejected_count'] . PHP_EOL;
echo 'To look up : ' . $batch['accepted_count'] . PHP_EOL;
// Keep batch_id: you need it to collect the results.const res = await fetch('https://sendpk.com/apps/hlr/lookup.php', {
method: 'POST',
headers: {
Authorization: process.env.SENDPK_API_KEY,
'Content-Type': 'application/json',
'Idempotency-Key': 'my-job-2026-09-20-01'
},
body: JSON.stringify({
type: 'mnp',
numbers: yourNumbers,
notification_callback: 'https://example.com/webhooks/lookup'
})
});
const reply = await res.json();
if (!reply.success) throw new Error(reply.error.code + ': ' + reply.error.message);
const b = reply.data;
console.log('Batch:', b.batch_id);
console.log(`sent ${b.total_count}, cached ${b.cached_count}, ` +
`repeats ${b.duplicate_count}, to look up ${b.accepted_count}`);import os, requests
reply = requests.post(
'https://sendpk.com/apps/hlr/lookup.php',
json={
'type': 'mnp',
'numbers': your_numbers, # up to 100000
'notification_callback': 'https://example.com/webhooks/lookup', # optional
},
headers={
'Authorization': os.environ['SENDPK_API_KEY'],
'Idempotency-Key': 'my-job-2026-09-20-01',
},
timeout=60,
).json()
if not reply['success']:
raise SystemExit(f"Rejected: {reply['error']['code']} - {reply['error']['message']}")
b = reply['data']
print('Batch:', b['batch_id'])
print(f"sent {b['total_count']}, cached {b['cached_count']}, "
f"repeats {b['duplicate_count']}, to look up {b['accepted_count']}")200 Response
{
"success": true,
"data": {
"batch_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c",
"lookup_method": "mnp",
"status": "processing",
"total_count": 3,
"accepted_count": 1,
"submitted_count": 0,
"completed_count": 0,
"pending_count": 1,
"rejected_count": 1,
"rejected": ["rubbish"],
"cached_count": 0,
"duplicate_count": 1,
"duplicates": ["+923001234567"],
"created_at": "2026-09-20T16:07:11+05:00",
"completed_at": null,
"error": null,
"results": [ "one entry per retained number" ],
"results_offset": 0,
"results_limit": 1000,
"results_returned": 2,
"results_total": 2,
"has_more": false
},
"meta": { "request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c" }
}What the counts mean
| Field | Meaning |
|---|---|
batch_id | The id of this bulk request. Keep it — you need it to collect results. |
lookup_method | hlr or mnp, as you asked. |
status | processing, completed or expired. See Statuses. |
total_count | Numbers you sent, repeats included. |
cached_count | Answered from our store. No network request; charged at the reduced cache price. |
duplicate_count | How many repeated normalized numbers were removed. Only the first occurrence is stored and may be charged. |
duplicates | Removed inputs, in input order, after the API's standard trimming. Included in the initial POST response and an idempotent POST retry; not stored or repeated in later GET/webhook responses. |
rejected_count | Not usable as a phone number, or refused by the provider. Never charged. |
accepted_count | Real lookups we owe you an answer for. |
submitted_count | Of those, how many have already gone out to the networks. |
completed_count | How many now have a final answer. |
pending_count | accepted_count minus completed_count — what is still outstanding. |
rejected | The numbers that were refused, exactly as you sent them. |
error | null normally. Filled in if the whole batch failed or gave up. |
results | One entry per retained input, in the order of first occurrence. See Batch Results. |
Big batches answer with counts only
When a request retains 1,000 numbers or fewer after deduplication, the reply includes
results, with any cached answers filled in. Above that, results is empty and you
collect the answers with GET ?batch_id=. This keeps a
100,000-number reply small and fast.
Batch Results GET
Query parameters
| Name | Type | Description |
|---|---|---|
batch_id required | string | The id you got from the bulk request. |
offset optional | integer | Where to start in the list. Default 0. |
limit optional | integer | How many results to return. Default and maximum 1000. |
Request
curl -G 'https://sendpk.com/apps/hlr/lookup.php' \
--data-urlencode 'batch_id=01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c' \
--data-urlencode 'offset=0' \
--data-urlencode 'limit=1000' \
-H 'Authorization: YOUR_API_KEY'200 Response
{
"success": true,
"data": {
"batch_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c",
"lookup_method": "mnp",
"status": "completed",
"total_count": 3,
"accepted_count": 1,
"completed_count": 1,
"results": [
{
"id": "a2ca5e09-d4fb-7732-bdf1-23a2405e4761",
"original_msisdn": "923001234567",
"phone_number": "+923001234567",
"cost": 0.78,
"currency": "PKR",
"number_type": "mobile",
"processing_status": "completed",
"imsi": "410030000000000",
"current_network": {
"country_iso2": "PK", "country_prefix": 92, "country_name": "Pakistan",
"mccmnc": "41003", "mcc": "410", "mnc": "03",
"area": "PK", "network_name": "Ufone"
},
"cached": false
},
{
"original_msisdn": "rubbish",
"phone_number": null,
"processing_status": "rejected",
"cost": 0,
"currency": "PKR",
"cached": false,
"error": {
"error": "INVALID_NUMBER",
"description": "The number is not a valid phone number."
}
}
],
"results_offset": 0,
"results_limit": 1000,
"results_returned": 2,
"results_total": 2,
"has_more": false
},
"meta": { "request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c" }
}Your order is always kept
Answers may finish in any order, but results keeps the original order of retained inputs.
Only the first occurrence of a repeated normalized number appears.
Match each result on original_msisdn. Removed repeats are listed only in the
initial POST response under duplicates; later GET and webhook responses include their count.
Walking through a big batch
Use offset and limit, and stop when has_more is false.
<?php
$offset = 0;
$all = [];
do {
$query = http_build_query([
'batch_id' => $batchId,
'offset' => $offset,
'limit' => 1000, // 1000 is the maximum
]);
$ch = curl_init('https://sendpk.com/apps/hlr/lookup.php?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: ' . getenv('SENDPK_API_KEY')],
CURLOPT_TIMEOUT => 60,
]);
$reply = json_decode((string) curl_exec($ch), true);
curl_close($ch);
if (empty($reply['success'])) {
exit('Failed: ' . $reply['error']['code'] . PHP_EOL);
}
$page = $reply['data'];
$all = array_merge($all, $page['results']);
$offset += $page['results_returned'];
} while ($page['has_more']);
echo 'Collected ' . count($all) . ' results, batch is ' . $page['status'] . PHP_EOL;
A batch is only finished when status is completed. Until then some entries still say
pending or queued. Poll every 30–60 seconds, or let us
tell you instead.
Result Fields
Every field a lookup can return, and when you get it.
The number
| Field | Type | Meaning |
|---|---|---|
id | string | The id of this lookup. A cached answer keeps the id of the original lookup. |
phone_number | string | The number in international (E.164) form. null when it could not be read at all. |
original_msisdn | string | Exactly what you sent, so you can match the answer to your own list. |
number_type | string | What kind of number it is — see the table below. |
timezone | string | Timezone of the place the number was issued, e.g. Asia/Karachi. It does not track the person. |
is_disposable | boolean | true when the number looks like a throwaway or virtual number. |
format.e164 | string | +923330189315 |
format.international | string | +92 333 0189315 |
format.national | string | 0333 0189315 |
format.rfc3966 | string | tel:+92-333-0189315 — for tel: links |
The networks
current_network is where the number is now — use this one for routing.
original_network is the network that first issued it (HLR only). On a ported number they differ.
Any field can be null when the operator does not publish it.
| Field | Type | Meaning |
|---|---|---|
…network.network_name | string | Operator name, e.g. Zong, Telenor, Jazz, Ufone. |
…network.mccmnc | string | The network id: MCC and MNC joined, e.g. 41004. This is what SMS routing uses. |
…network.mcc | string | Mobile Country Code, 3 characters, e.g. 410 for Pakistan. |
…network.mnc | string | Mobile Network Code, 2–3 characters, e.g. 04. Leading zeros matter — keep it as text. |
…network.country_iso2 | string | Country letters, e.g. PK. |
…network.country_prefix | integer | Dialling code, e.g. 92. |
…network.country_name | string | Country name, e.g. Pakistan. |
…network.area | string | Area or region, where it can be identified. |
HLR only
| Field | Type | Meaning |
|---|---|---|
reachable | string | Whether the phone can be reached right now — see Statuses. |
is_ported | boolean | true when the number has moved to another network. null when the network will not say. |
imsi | string | The SIM card's subscriber id. null when the operator does not release it. |
original_network | object | The network that issued the number. |
Money and cache
| Field | Type | Meaning |
|---|---|---|
cost | number | What this lookup cost you, in PKR. 0 means you were not charged. |
currency | string | Always PKR. |
cached | boolean | true when the answer came from our store instead of the network. |
cache_expires_at | string | Only on a cached answer: how long that answer may still be reused. |
processing_status | string | How the lookup ended — see Statuses. |
error | object | Only on a number that failed: error (the code) and description. |
number_type values
| Value | Meaning |
|---|---|
mobile | A mobile number. |
landline | A fixed line. |
mobile_or_landline | Cannot be told apart from the number alone (for example USA and Canada). A lookup can still work. |
toll_free | Free to call. |
premium_rate | Extra charges apply. |
shared_cost | Cost shared between caller and receiver. |
personal_number | Follows a person; may ring a mobile or a landline. |
voip | Voice over IP. |
pager | Pager; usually no voice. |
uan | Universal Access Number (one company number). |
voicemail | A voicemail number. |
unknown | Could not be worked out. |
Statuses
Three small vocabularies: one per lookup, one per phone, one per batch.
processing_status — how one lookup ended
| Value | Meaning | Charged? |
|---|---|---|
completed | The lookup ran and gave a valid answer. | Yes |
rejected | The number does not qualify — usually invalid, or a landline. | Cache rate |
failed | The lookup ran but hit an error. | Cache rate |
pending | Bulk only. Accepted and written down, not sent to the networks yet. | Not yet |
queued | Bulk only. With the networks, waiting for the answer. | Not yet |
reachable — is the phone on? (HLR only)
| Value | Meaning | Charged? |
|---|---|---|
connected | Active number, switched on and reachable. | Yes |
absent | Not reachable now: switched off, no signal, or unused for several days. | Yes |
no-teleservice-provisioned | Cannot take calls or SMS — usually a data-only SIM. | Yes |
inconclusive | The network would not give an answer. | Yes |
no-coverage | No live route to that network. Nothing is learned. | Cache rate |
failed | The network gave an error. | Cache rate |
invalid | The number is not real. | Cache rate |
About absent
absent is a real, useful answer — the number exists, it is just not reachable at
this moment. It usually means the phone is off or out of signal, or has not been switched on for several
days. A SIM that has been allocated but never used also shows as absent.
status — how a bulk batch is doing
| Value | Meaning |
|---|---|
processing | Still running. Some numbers have no answer yet. |
completed | Finished. Every accepted number has a final answer. |
expired | Gave up waiting after 12 hours. Unanswered numbers are marked failed and every rupee still held is returned. |
The 60-Day Cache
Ask twice, pay once and a quarter.
Every useful lookup is stored for 60 days. Ask for the same number with the same
type inside that window and you get the stored answer back — instantly, with no request to
the network, and at a reduced price.
| Rule | What it means for you |
|---|---|
| Kept for 60 days | After that the next request does a fresh lookup and starts a new 60 days. |
| Key is number + type | An MNP answer is never used for an HLR lookup, or the other way round. They are kept completely apart. |
| Formats merged first | +923330189315, 923330189315, 00923330189315 and 03330189315 share one entry. |
| Only useful answers kept | A no-coverage, invalid or failed answer is not stored, so you are never stuck with a bad result if a route is added later. |
| You can see it | A cached reply carries "cached": true and cache_expires_at. |
Bulk requests check the cache first
In a bulk request the whole list is checked against the store before anything is sent out. If you send 1,000 numbers and 600 are already known, only 400 go to the networks — and money is only held for those 400.
Billing
You pay for answers, not for attempts.
Charges come out of the same PKR balance your SMS packages use. The cost field in every result
is your price, in PKR, for that one number.
| Situation | You pay |
|---|---|
| Fresh lookup that found something | Full price |
| Answer from our 60-day store | 25% of the full price |
Lookup ran but found nothing (no-coverage, rejected, failed) | 25% of the full price |
| Same number twice in one request | Once |
| Number we could not read — never sent | Nothing |
| Provider timeout or outage | Nothing |
Batch gave up waiting (expired) | Nothing for the unanswered numbers |
An HLR lookup costs about twice an MNP lookup. The cache share is 25% by default and can be set differently for your account — ask SENDPK support. Your exact rates are on the pricing page or from your account manager.
Why a lookup that found nothing still costs something. It went out to the network, came back and was written down, so it used the service. You pay the same reduced share a cached answer costs — never the full price. Only a request that never left our servers is completely free: a number we could not read, or a provider timeout.
Bulk requests hold money up front
A bulk request is answered before the networks reply, so the money is taken when the batch is accepted and the unused part is given straight back as each answer lands.
| Step | What happens |
|---|---|
| 1 | Your list is tidied up, repeats are merged and the cache is checked. |
| 2 | Money is held for the fresh numbers only. Cached, repeated and rejected numbers are not counted. |
| 3 | If your balance cannot cover it, the request is refused with INSUFFICIENT_BALANCE and nothing is sent out. |
| 4 | As each answer arrives the real price is kept and the rest is returned the same minute. |
Worked example
You send 1,000 numbers for MNP. 600 are already in the store, 50 are repeats and 20 cannot be read, leaving 330 fresh lookups. Money is held for those 330 only. The 600 cached answers are charged at 25%, and the 50 repeats and 20 unusable numbers cost nothing.
Single lookups
No money is held. Your balance is checked before the lookup is made — if it is too low you get
INSUFFICIENT_BALANCE and nothing is sent out — and the real price is taken once the answer is in.
Idempotency
Retry a bulk request safely, without paying twice.
If your request times out you often cannot tell whether it arrived. Send an Idempotency-Key
header with your bulk request and you can simply send it again.
| You send | What happens |
|---|---|
| Same key, same numbers | You get the first batch back. No second set of lookups, no second charge. |
| Same key, different numbers | IDEMPOTENCY_CONFLICT — 409. The key is already taken. |
| No key | Every request is treated as new. A retry means a second batch and a second charge. |
A key is remembered for 24 hours and must be 1–100 visible ASCII characters with no spaces. Use something from your own system, such as a job id or an order number.
Limits
There is no per-minute and no per-day limit. You may look up as much as your balance can pay for.
| Limit | Value | If you go over |
|---|---|---|
| Numbers per bulk request | 100,000 | BULK_LIMIT_EXCEEDED — 422 |
| Request body size | 2 MB | PAYLOAD_TOO_LARGE — 413 |
| Results per page | 1,000 | Quietly reduced to 1,000 |
| Results inside a POST reply | Up to 1,000 retained inputs | Bigger retained batches answer with counts only |
| How long a batch waits | 12 hours | Batch becomes expired, money returned |
| Idempotency-Key memory | 24 hours | After that the key can be reused |
| Failed API keys | Throttled per IP | RATE_LIMIT_EXCEEDED — 429 |
Numbers are handed to the networks in chunks of 500, a few seconds apart, so a very large batch fills in
gradually. Watch submitted_count and completed_count climb, or wait for the
webhook.
Webhooks
Let us tell you when a bulk batch is finished, instead of asking us over and over.
Put a notification_callback URL in your bulk request and we POST the finished batch to it.
The body uses the same batch and result fields as
GET ?batch_id=, plus an event field and pagination
metadata, so one reader handles both.
POST https://example.com/webhooks/lookup
Content-Type: application/json
X-SENDPK-Event: lookup.batch.completed
X-SENDPK-Event-Id: 8f14e45f-ceea-5e7a-9f1b-2c3d4e5f6a7b
X-SENDPK-Signature: sha256=9b2c... (HMAC-SHA256 of the raw body, key = your API key){
"batch_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c",
"status": "completed",
"results": [ "up to 500 result objects" ],
"results_offset": 0,
"results_limit": 500,
"results_returned": 500,
"results_total": 501,
"has_more": true,
"event": "lookup.batch.completed"
}
If has_more is true, fetch the next page with authenticated
GET ?batch_id=<id>&offset=<results_returned>&limit=1000.
| Event | When |
|---|---|
lookup.batch.completed | Every accepted number has a final answer. |
lookup.batch.expired | The batch gave up waiting after 12 hours. Money still held has been returned. |
Rules for your URL
- Must be
https— plainhttpis refused - Must be a public address — private and internal addresses are refused
- Must not point back at a SENDPK domain
- Port 443 or 8443
- Max 500 characters
A URL that breaks any of these is refused straight away with INVALID_CALLBACK_URL.
Delivery
| Thing | Behaviour |
|---|---|
| Success | Any 2xx. Answer quickly and do the work afterwards. |
| Retries | 3 attempts. After the first failure we wait 60 seconds, then 300 seconds. |
| Retried for | Timeouts, network errors, 408, 429 and any 5xx. |
| Not retried | Other 4xx, and redirects (we do not follow them). |
| Big batches | The webhook includes the first 500 results. Check results_returned, results_total and has_more; when more remain, fetch them with GET ?batch_id=. The page is bounded so an unusually large batch cannot create an oversized webhook request. |
Webhook Security
Always check the signature before you trust a webhook.
Every webhook carries X-SENDPK-Signature: sha256= followed by an HMAC-SHA256 of the
exact raw body, signed with your API key. Anyone can POST to your URL, so this
is how you know the message really came from SENDPK.
<?php
// Read the RAW body. Do not json_decode and re-encode it: the signature
// is over the exact bytes we sent.
$raw = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_SENDPK_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('SENDPK_API_KEY'));
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$batch = json_decode($raw, true);
// The same event may reach you more than once. Remember the event id and
// skip anything you have already handled.
$eventId = $_SERVER['HTTP_X_SENDPK_EVENT_ID'] ?? '';
if (already_processed($eventId)) {
http_response_code(200);
exit;
}
// Answer FIRST, then do the slow work, so we never time out.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
process_batch($batch); // $batch['batch_id'], $batch['status'], $batch['results']import crypto from 'node:crypto';
import express from 'express';
const app = express();
// express.raw, not express.json: the signature covers the exact bytes.
app.post('/webhooks/lookup', express.raw({ type: 'application/json' }), (req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.SENDPK_API_KEY)
.update(req.body)
.digest('hex');
const given = req.get('X-SENDPK-Signature') || '';
const ok = given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
res.sendStatus(200); // answer first
const batch = JSON.parse(req.body.toString());
const eventId = req.get('X-SENDPK-Event-Id');
if (!alreadyProcessed(eventId)) processBatch(batch);
});import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/webhooks/lookup')
def lookup_hook():
raw = request.get_data() # raw bytes, not request.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)
event_id = request.headers.get('X-SENDPK-Event-Id', '')
if not already_processed(event_id):
queue_batch(request.get_json()) # hand off, answer straight away
return '', 200Three things people get wrong
Use the raw body. Decoding the JSON and encoding it again changes the bytes, and the signature will never match.
Use hash_equals / timingSafeEqual. A plain ==
comparison leaks timing information.
Answer 200 quickly. Do the slow work after replying. If you take
too long we treat it as a failure and retry.
Code Examples
Two jobs people actually do with this API.
Route an SMS to the right network
A ported number still looks like its old network. One MNP lookup gives you the real one.
<?php
/**
* Work out the real network of a number before sending.
* Answers are stored for 60 days, so looking the same number up again
* costs a quarter of the price.
*/
function sendpk_current_network(string $number): ?array
{
$query = http_build_query(['type' => 'mnp', 'number' => $number]);
$ch = curl_init('https://sendpk.com/apps/hlr/lookup.php?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: ' . getenv('SENDPK_API_KEY')],
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 40,
]);
$body = curl_exec($ch);
curl_close($ch);
$reply = json_decode((string) $body, true);
if (empty($reply['success'])) {
return null; // log $reply['error']['code']
}
$network = $reply['data']['current_network'] ?? [];
if (($network['mccmnc'] ?? null) === null) {
return null; // nothing was learned
}
return [
'mccmnc' => $network['mccmnc'],
'name' => $network['network_name'] ?? null,
'country' => $network['country_iso2'] ?? null,
'cached' => (bool) $reply['data']['cached'],
'cost' => (float) $reply['data']['cost'],
];
}
$network = sendpk_current_network('923330189315');
echo $network ? "Route to {$network['name']} ({$network['mccmnc']})" : 'Unknown network';Clean a contact list
Send the whole list, wait for the batch, then keep only the numbers that are real.
<?php
$key = getenv('SENDPK_API_KEY');
/** Small helper: call the API and return the decoded reply. */
function sendpk_call(string $url, ?array $json, string $key, array $extraHeaders = []): array
{
$headers = array_merge(['Authorization: ' . $key], $extraHeaders);
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => $json === null
? $headers
: array_merge($headers, ['Content-Type: application/json']),
]);
if ($json !== null) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json));
}
$body = curl_exec($ch);
curl_close($ch);
return json_decode((string) $body, true)
?: ['success' => false, 'error' => ['code' => 'NO_REPLY', 'message' => 'No reply']];
}
// 1. Send the list. The same Idempotency-Key on a retry = no double charge.
$start = sendpk_call('https://sendpk.com/apps/hlr/lookup.php', [
'type' => 'hlr', // 'hlr' also tells us if the phone is ON
'numbers' => $contacts, // up to 100000
], $key, ['Idempotency-Key: cleanup-' . date('Y-m-d')]);
if (empty($start['success'])) {
exit('Rejected: ' . $start['error']['code'] . PHP_EOL);
}
$batchId = $start['data']['batch_id'];
echo "Batch $batchId: {$start['data']['accepted_count']} to look up, "
. "{$start['data']['cached_count']} already known" . PHP_EOL;
// 2. Wait for it. (A notification_callback avoids this loop entirely.)
do {
sleep(30);
$page = sendpk_call('https://sendpk.com/apps/hlr/lookup.php?' . http_build_query([
'batch_id' => $batchId, 'offset' => 0, 'limit' => 1,
]), null, $key);
$status = $page['data']['status'] ?? 'processing';
echo " ... $status ({$page['data']['completed_count']}/{$page['data']['accepted_count']})" . PHP_EOL;
} while ($status === 'processing');
// 3. Read every page and split the list.
$live = $dead = [];
$offset = 0;
do {
$page = sendpk_call('https://sendpk.com/apps/hlr/lookup.php?' . http_build_query([
'batch_id' => $batchId, 'offset' => $offset, 'limit' => 1000,
]), null, $key)['data'];
foreach ($page['results'] as $r) {
// 'connected' and 'absent' both mean the number is real.
// 'no-coverage' means we could not check - do NOT delete those.
if (in_array($r['reachable'] ?? '', ['connected', 'absent'], true)) {
$live[] = $r['phone_number'];
} elseif (($r['reachable'] ?? '') === 'invalid') {
$dead[] = $r['original_msisdn'];
}
}
$offset += $page['results_returned'];
} while ($page['has_more']);
echo 'Keep: ' . count($live) . ', remove: ' . count($dead) . PHP_EOL;Do not delete a number just because a lookup failed
Only reachable: "invalid" means the number is not real. no-coverage,
failed and inconclusive mean we could not check — the number may be
perfectly good. Treat those as "unknown", not as "dead".
Postman Collection
Every request on this page, ready to run. Fill in two boxes and press Send.
SENDPK HLR & MNP Lookup API
Postman Collection v2.1 — 16 requests in 6 folders, with the test scripts already written.
How to use it
Download the file, then in Postman choose Import and pick it.
Open the collection's Variables tab. Put your key in api_key.
Put a number you want to look up in phone_number.
Run Test my API key first. It tells you at once if anything is wrong.
What is inside
| Folder | What it does |
|---|---|
| 1. Authentication / Setup | Checks your key without spending anything. Tells you whether it is the key, your IP, or the service switch. |
| 2. Single Lookup | MNP, HLR, the same request again to show a cached answer, and one that proves different spellings are one number. |
| 3. Bulk Lookup | Bulk MNP and HLR, plus a safe-retry request you can run twice to see the same batch come back. |
| 4. Batch Results | Collect the answers and page through a big batch. |
| 5. What errors look like | Six requests that are meant to fail, so you can see the exact shape your code must handle. None of them cost anything. |
| 6. Webhooks | Sends a correctly signed SENDPK-shaped webhook to your server, so you can test your own handler. |
It fills in the awkward parts for you
Run a bulk request and the batch_id is saved automatically, so
Get batch results works with no copying and pasting.
The webhook request signs itself: a pre-request script builds the body and
works out the HMAC-SHA256 with your api_key, exactly the way we sign real webhooks. Open the
Postman Console (View → Show Postman Console) to see the network, the cost and whether the
answer came from the cache.
Never share the collection after you fill it in
Once your API key is in the Variables tab, the file holds a working key to your account. Do not commit it, email it, or paste it into a public workspace.
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 | Not enough balance for the lookups asked for. details.required shows the PKR needed. |
| 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 | HLR/MNP lookup 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 | BATCH_NOT_FOUND | No batch with this id on your account. |
| 405 | METHOD_NOT_ALLOWED | Only GET and POST are allowed. |
| 409 | IDEMPOTENCY_CONFLICT | Same Idempotency-Key used with different numbers. |
| 413 | PAYLOAD_TOO_LARGE | Body larger than 2 MB. |
| 415 | INVALID_CONTENT_TYPE | Content-Type must be application/json. |
| 422 | MISSING_LOOKUP_TYPE | type is missing. |
| 422 | INVALID_LOOKUP_TYPE | type is not hlr or mnp. |
| 422 | MISSING_NUMBER | Single lookup: number is missing. |
| 422 | INVALID_NUMBER | Not a valid phone number. |
| 422 | MISSING_NUMBERS | Bulk: the numbers field is missing. |
| 422 | INVALID_NUMBERS | numbers is not a plain array. |
| 422 | EMPTY_NUMBERS | The numbers array is empty. |
| 422 | BULK_LIMIT_EXCEEDED | More than 100,000 numbers. details.max and details.sent show both. |
| 422 | NO_VALID_NUMBERS | Not one number in the list could be read. |
| 422 | INVALID_BATCH_ID | batch_id is not a batch id. |
| 422 | NUMBER_NOT_MOBILE | Not a mobile number. |
| 422 | COUNTRY_NOT_SUPPORTED | Lookups are not available for this country. |
| 422 | NUMBER_BLOCKED | This destination is blocked. |
| 422 | LOOKUP_NOT_AVAILABLE | This lookup is not available for this number. |
| 422 | PROVIDER_NO_ROUTE | No lookup route for this destination. |
| 422 | INVALID_CALLBACK_URL | Webhook URL is not allowed — see the rules in Webhooks. |
| 429 | RATE_LIMIT_EXCEEDED | Too many failed API keys from your IP. Wait and try again. |
| 429 | PROVIDER_RATE_LIMITED | The lookup provider is throttling us. Retry shortly. |
| 500 | INTERNAL_ERROR | Something broke on our side. Retry, then contact support with your request_id. |
| 500 | BILLING_ERROR | A billing error occurred. |
| 502 | PROVIDER_REJECTED | The lookup provider refused the request. |
| 502 | PROVIDER_INVALID_RESPONSE | The lookup provider sent something unreadable. |
| 503 | SERVICE_UNAVAILABLE | The service is temporarily unavailable. |
| 503 | PROVIDER_UNAVAILABLE | The lookup provider is temporarily unavailable. |
| 503 | SERVICE_BUSY | Another request of yours is being handled. Retry in about 2 seconds. |
| 504 | PROVIDER_TIMEOUT | The lookup provider did not answer in time. |
Errors inside a bulk result
A problem with one number never spoils the batch. That number comes back in its own
place with "processing_status": "rejected" or "failed" and its own small
error object holding error (the code) and description. Everything else is
returned normally.
Frequently Asked Questions
Best Practices
Follow these and most problems never happen.
Security
- Always use HTTPS. Plain http is refused.
- Call this API from your server only — never from an app or a browser.
- Keep the API key in an environment variable, or a file outside your public folder.
- Turn on IP restriction in your dashboard, so a leaked key is useless elsewhere.
- Always check the webhook signature against the raw body.
- Use
X-SENDPK-Event-Idso a repeated webhook is not handled twice.
Keeping the cost down
- Use
mnpunless you really need to know the phone is switched on. - Send one big bulk request rather than many single ones — repeats and cached numbers are found for you.
- Send an Idempotency-Key on every bulk request, so a retry never charges twice.
- Store the answers on your side too. Ours are kept 60 days, but a lookup you never make is free.
Writing the code
- Check
successfirst, then switch onerror.code— never on the message text. - Treat
mccmnc,mccandmncas text. Leading zeros matter. - Expect
null: any network field may be missing when the operator will not publish it. - Route on
current_network, never onoriginal_network. - Use a webhook instead of polling a big batch in a tight loop.
- Give a single lookup a 40-second timeout — an HLR query talks to a real network.
- Save
request_idandbatch_idin your logs for troubleshooting.
Support
Need help integrating?
Our team can switch the lookup service on for your account, whitelist your server IP, set
your cache price, or work out why a lookup did not return what you expected. Keep your
request_id or batch_id ready — it lets us find your exact request straight away.
Looking for our other APIs? See the SMS & WhatsApp API documentation or the Missed Call Verification API.