PTA-Approved SMS Aggregator · Serving Pakistan since 2014

Lookup API

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

https://sendpk.com/apps/hlr/lookup.php

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.

1

Get your SENDPK API key from your dashboard.

2

Ask SENDPK support to switch the lookup service on.

3

Send type and number to lookup.php

4

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.

Important

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
Overview

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=mnptype=hlr
Tells youThe network the number is on nowThe network and whether the phone is reachable
Current networkYesYes
Original networkNoYes
reachableNoYes
is_portedNoYes
imsiWhen the operator gives itWhen the operator gives it
Pakistan coverageEvery networkOnly networks with a live route
PriceLowerAbout 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.

Getting Started

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

Authorization: YOUR_API_KEY

This form also works:

Authorization: Bearer YOUR_API_KEY

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

CheckIf it fails
Connection is httpsHTTPS_REQUIRED — 403
Not too many failed keys latelyRATE_LIMIT_EXCEEDED — 429
Header is presentAUTH_MISSING — 401
Key is correctAUTH_INVALID — 401
Account is not blockedACCOUNT_DISABLED — 403
Key is not marked for changeAPI_KEY_REVOKED — 401
Server IP is allowed (if IP lock is on)IP_NOT_ALLOWED — 403
Lookup service is enabled on the accountSERVICE_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.

Getting Started

Base URL & Endpoint

There is one URL. The HTTP method and the parameters decide what happens.

https://sendpk.com/apps/hlr/lookup.php
MethodRequestWhat 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

JSON
{
  "success": true,
  "data": {},
  "meta": {
    "request_id": "01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c"
  }
}

ERROR

JSON
{
  "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"
  }
}
FieldMeaning
successTells you if the request worked: true or false. Check this first.
dataThe result, when success is true.
errorThe problem, when success is false: a code, a message and extra details.
meta.request_idIdentifies 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.

Getting Started

Number Formats

Send numbers however you have them. They are all tidied up the same way before anything else happens.

You sendBecomesNote
+923330189315+923330189315International — the safest form
923330189315+923330189315Country code without the plus
00923330189315+923330189315Leading 00
03330189315+923330189315Pakistani local form
3330189315+923330189315Pakistani, no leading zero
+92 333 018-9315+923330189315Spaces, 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.

API Reference

Single Lookup GET

https://sendpk.com/apps/hlr/lookup.php?type=mnp&number=923330189315

Headers

HeaderValue
Authorization requiredYour SENDPK API key.

Query parameters

NameTypeDescription
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
curl -G 'https://sendpk.com/apps/hlr/lookup.php' \
  --data-urlencode 'type=mnp' \
  --data-urlencode 'number=923330189315' \
  -H 'Authorization: YOUR_API_KEY'
PHP
<?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;
}
JavaScript
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);
}
Python
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

JSON
{
  "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.

JSON
{
  "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.

JSON
{
  "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.

JSON
{
  "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.

API Reference

Bulk Lookup POST

https://sendpk.com/apps/hlr/lookup.php

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

HeaderValue
Authorization requiredYour SENDPK API key.
Content-Type requiredapplication/json — anything else is INVALID_CONTENT_TYPE.
Idempotency-Key optional1–100 visible ASCII characters. See Idempotency.

Body parameters

NameTypeDescription
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
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
<?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.
JavaScript
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}`);
Python
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

JSON
{
  "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

FieldMeaning
batch_idThe id of this bulk request. Keep it — you need it to collect results.
lookup_methodhlr or mnp, as you asked.
statusprocessing, completed or expired. See Statuses.
total_countNumbers you sent, repeats included.
cached_countAnswered from our store. No network request; charged at the reduced cache price.
duplicate_countHow many repeated normalized numbers were removed. Only the first occurrence is stored and may be charged.
duplicatesRemoved 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_countNot usable as a phone number, or refused by the provider. Never charged.
accepted_countReal lookups we owe you an answer for.
submitted_countOf those, how many have already gone out to the networks.
completed_countHow many now have a final answer.
pending_countaccepted_count minus completed_count — what is still outstanding.
rejectedThe numbers that were refused, exactly as you sent them.
errornull normally. Filled in if the whole batch failed or gave up.
resultsOne 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.

API Reference

Batch Results GET

https://sendpk.com/apps/hlr/lookup.php?batch_id=01a0be7f-a551-7cd6-bf56-4ba0da6a9b8c

Query parameters

NameTypeDescription
batch_id requiredstringThe id you got from the bulk request.
offset optionalintegerWhere to start in the list. Default 0.
limit optionalintegerHow many results to return. Default and maximum 1000.

Request

cURL
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

JSON
{
  "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
<?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.

API Reference

Result Fields

Every field a lookup can return, and when you get it.

The number

FieldTypeMeaning
idstringThe id of this lookup. A cached answer keeps the id of the original lookup.
phone_numberstringThe number in international (E.164) form. null when it could not be read at all.
original_msisdnstringExactly what you sent, so you can match the answer to your own list.
number_typestringWhat kind of number it is — see the table below.
timezonestringTimezone of the place the number was issued, e.g. Asia/Karachi. It does not track the person.
is_disposablebooleantrue when the number looks like a throwaway or virtual number.
format.e164string+923330189315
format.internationalstring+92 333 0189315
format.nationalstring0333 0189315
format.rfc3966stringtel:+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.

FieldTypeMeaning
…network.network_namestringOperator name, e.g. Zong, Telenor, Jazz, Ufone.
…network.mccmncstringThe network id: MCC and MNC joined, e.g. 41004. This is what SMS routing uses.
…network.mccstringMobile Country Code, 3 characters, e.g. 410 for Pakistan.
…network.mncstringMobile Network Code, 2–3 characters, e.g. 04. Leading zeros matter — keep it as text.
…network.country_iso2stringCountry letters, e.g. PK.
…network.country_prefixintegerDialling code, e.g. 92.
…network.country_namestringCountry name, e.g. Pakistan.
…network.areastringArea or region, where it can be identified.

HLR only

FieldTypeMeaning
reachablestringWhether the phone can be reached right now — see Statuses.
is_portedbooleantrue when the number has moved to another network. null when the network will not say.
imsistringThe SIM card's subscriber id. null when the operator does not release it.
original_networkobjectThe network that issued the number.

Money and cache

FieldTypeMeaning
costnumberWhat this lookup cost you, in PKR. 0 means you were not charged.
currencystringAlways PKR.
cachedbooleantrue when the answer came from our store instead of the network.
cache_expires_atstringOnly on a cached answer: how long that answer may still be reused.
processing_statusstringHow the lookup ended — see Statuses.
errorobjectOnly on a number that failed: error (the code) and description.

number_type values

ValueMeaning
mobileA mobile number.
landlineA fixed line.
mobile_or_landlineCannot be told apart from the number alone (for example USA and Canada). A lookup can still work.
toll_freeFree to call.
premium_rateExtra charges apply.
shared_costCost shared between caller and receiver.
personal_numberFollows a person; may ring a mobile or a landline.
voipVoice over IP.
pagerPager; usually no voice.
uanUniversal Access Number (one company number).
voicemailA voicemail number.
unknownCould not be worked out.
API Reference

Statuses

Three small vocabularies: one per lookup, one per phone, one per batch.

processing_status — how one lookup ended

ValueMeaningCharged?
completedThe lookup ran and gave a valid answer.Yes
rejectedThe number does not qualify — usually invalid, or a landline.Cache rate
failedThe lookup ran but hit an error.Cache rate
pendingBulk only. Accepted and written down, not sent to the networks yet.Not yet
queuedBulk only. With the networks, waiting for the answer.Not yet

reachable — is the phone on? (HLR only)

ValueMeaningCharged?
connectedActive number, switched on and reachable.Yes
absentNot reachable now: switched off, no signal, or unused for several days.Yes
no-teleservice-provisionedCannot take calls or SMS — usually a data-only SIM.Yes
inconclusiveThe network would not give an answer.Yes
no-coverageNo live route to that network. Nothing is learned.Cache rate
failedThe network gave an error.Cache rate
invalidThe 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

ValueMeaning
processingStill running. Some numbers have no answer yet.
completedFinished. Every accepted number has a final answer.
expiredGave up waiting after 12 hours. Unanswered numbers are marked failed and every rupee still held is returned.
Cache & Billing

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.

RuleWhat it means for you
Kept for 60 daysAfter that the next request does a fresh lookup and starts a new 60 days.
Key is number + typeAn 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 keptA 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 itA 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.

Cache & Billing

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.

SituationYou pay
Fresh lookup that found somethingFull price
Answer from our 60-day store25% of the full price
Lookup ran but found nothing (no-coverage, rejected, failed)25% of the full price
Same number twice in one requestOnce
Number we could not read — never sentNothing
Provider timeout or outageNothing
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.

StepWhat happens
1Your list is tidied up, repeats are merged and the cache is checked.
2Money is held for the fresh numbers only. Cached, repeated and rejected numbers are not counted.
3If your balance cannot cover it, the request is refused with INSUFFICIENT_BALANCE and nothing is sent out.
4As 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.

Cache & Billing

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.

Idempotency-Key: my-job-2026-09-20-01
You sendWhat happens
Same key, same numbersYou get the first batch back. No second set of lookups, no second charge.
Same key, different numbersIDEMPOTENCY_CONFLICT — 409. The key is already taken.
No keyEvery 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.

Cache & Billing

Limits

There is no per-minute and no per-day limit. You may look up as much as your balance can pay for.

LimitValueIf you go over
Numbers per bulk request100,000BULK_LIMIT_EXCEEDED — 422
Request body size2 MBPAYLOAD_TOO_LARGE — 413
Results per page1,000Quietly reduced to 1,000
Results inside a POST replyUp to 1,000 retained inputsBigger retained batches answer with counts only
How long a batch waits12 hoursBatch becomes expired, money returned
Idempotency-Key memory24 hoursAfter that the key can be reused
Failed API keysThrottled per IPRATE_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

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.

HTTP
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)
JSON
{
  "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.

EventWhen
lookup.batch.completedEvery accepted number has a final answer.
lookup.batch.expiredThe batch gave up waiting after 12 hours. Money still held has been returned.

Rules for your URL

  • Must be https — plain http is 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

ThingBehaviour
SuccessAny 2xx. Answer quickly and do the work afterwards.
Retries3 attempts. After the first failure we wait 60 seconds, then 300 seconds.
Retried forTimeouts, network errors, 408, 429 and any 5xx.
Not retriedOther 4xx, and redirects (we do not follow them).
Big batchesThe 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.
Webhooks

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
<?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']
JavaScript
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);
});
Python
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 '', 200

Three 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.

Developer Tools

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
<?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
<?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".

Developer Tools

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

1

Download the file, then in Postman choose Import and pick it.

2

Open the collection's Variables tab. Put your key in api_key.

3

Put a number you want to look up in phone_number.

4

Run Test my API key first. It tells you at once if anything is wrong.

What is inside

FolderWhat it does
1. Authentication / SetupChecks your key without spending anything. Tells you whether it is the key, your IP, or the service switch.
2. Single LookupMNP, HLR, the same request again to show a cached answer, and one that proves different spellings are one number.
3. Bulk LookupBulk MNP and HLR, plus a safe-retry request you can run twice to see the same batch come back.
4. Batch ResultsCollect the answers and page through a big batch.
5. What errors look likeSix requests that are meant to fail, so you can see the exact shape your code must handle. None of them cost anything.
6. WebhooksSends 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.

Important

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.

Reference

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.

HTTPCodeMeaning
400INVALID_JSONBody is not a valid JSON object.
400INVALID_IDEMPOTENCY_KEYIdempotency-Key is empty, too long or has spaces.
400BAD_REQUESTThe request could not be understood.
401AUTH_MISSINGNo API key in the Authorization header.
401AUTH_INVALIDWrong API key.
401API_KEY_REVOKEDYour API key must be changed in the dashboard.
402INSUFFICIENT_BALANCENot enough balance for the lookups asked for. details.required shows the PKR needed.
403ACCOUNT_DISABLEDAccount blocked.
403IP_NOT_ALLOWEDYour server IP is not whitelisted. details.ip shows the IP we saw.
403SERVICE_DISABLEDHLR/MNP lookup is not switched on for your account.
403HTTPS_REQUIREDThe request used plain http. Use https://.
403FORBIDDENAccess not allowed.
404NOT_FOUNDWrong URL.
404BATCH_NOT_FOUNDNo batch with this id on your account.
405METHOD_NOT_ALLOWEDOnly GET and POST are allowed.
409IDEMPOTENCY_CONFLICTSame Idempotency-Key used with different numbers.
413PAYLOAD_TOO_LARGEBody larger than 2 MB.
415INVALID_CONTENT_TYPEContent-Type must be application/json.
422MISSING_LOOKUP_TYPEtype is missing.
422INVALID_LOOKUP_TYPEtype is not hlr or mnp.
422MISSING_NUMBERSingle lookup: number is missing.
422INVALID_NUMBERNot a valid phone number.
422MISSING_NUMBERSBulk: the numbers field is missing.
422INVALID_NUMBERSnumbers is not a plain array.
422EMPTY_NUMBERSThe numbers array is empty.
422BULK_LIMIT_EXCEEDEDMore than 100,000 numbers. details.max and details.sent show both.
422NO_VALID_NUMBERSNot one number in the list could be read.
422INVALID_BATCH_IDbatch_id is not a batch id.
422NUMBER_NOT_MOBILENot a mobile number.
422COUNTRY_NOT_SUPPORTEDLookups are not available for this country.
422NUMBER_BLOCKEDThis destination is blocked.
422LOOKUP_NOT_AVAILABLEThis lookup is not available for this number.
422PROVIDER_NO_ROUTENo lookup route for this destination.
422INVALID_CALLBACK_URLWebhook URL is not allowed — see the rules in Webhooks.
429RATE_LIMIT_EXCEEDEDToo many failed API keys from your IP. Wait and try again.
429PROVIDER_RATE_LIMITEDThe lookup provider is throttling us. Retry shortly.
500INTERNAL_ERRORSomething broke on our side. Retry, then contact support with your request_id.
500BILLING_ERRORA billing error occurred.
502PROVIDER_REJECTEDThe lookup provider refused the request.
502PROVIDER_INVALID_RESPONSEThe lookup provider sent something unreadable.
503SERVICE_UNAVAILABLEThe service is temporarily unavailable.
503PROVIDER_UNAVAILABLEThe lookup provider is temporarily unavailable.
503SERVICE_BUSYAnother request of yours is being handled. Retry in about 2 seconds.
504PROVIDER_TIMEOUTThe 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.

Reference

Frequently Asked Questions

What is an HLR lookup?
HLR stands for Home Location Register, the database every mobile network keeps about its own SIM cards. An HLR lookup asks that database, in real time, whether a number is live and which network it is on right now. It also tells you whether the phone is switched on and reachable.
What is an MNP lookup?
MNP stands for Mobile Number Portability. When somebody keeps their number but moves to another network, the number no longer matches its original network. An MNP lookup returns the network the number belongs to today, including its MCCMNC. It costs less than an HLR lookup and it is the right choice when all you need is the correct network.
Which one should I use?
Use MNP when you only need the current network, for example to route SMS to the right operator. Use HLR when you also need to know whether the phone is switched on, for example when cleaning a contact database.
Does HLR work for every Pakistani number?
No. Live reachability is only available on the networks our provider has a route to. In Pakistan that is Zong and Telenor. For any other Pakistani network an HLR lookup returns reachable "no-coverage", charged at the reduced cache rate rather than the full price. MNP works on every Pakistani network.
Am I charged when a lookup finds nothing?
Yes, but only the reduced cache rate, never the full price. The lookup still went out to the network and came back, so it used the service. That covers reachable "no-coverage", numbers the network rejected, and lookups that failed. A request that never left our servers is completely free: a number we could not read, or a provider timeout.
How long do you keep a result?
Sixty days. Ask for the same number and the same lookup type again inside that time and you get the stored answer back, marked cached true, with no request to the network.
Do I pay for a cached answer?
Yes, but much less: 25 percent of the normal price by default. Your account can be set to a different share, or to zero, by SENDPK.
Is an MNP result used to answer an HLR lookup?
No. The two are kept completely apart. An MNP answer never stands in for an HLR one, and the other way round.
What happens if I send the same number twice in one request?
Only the first occurrence is kept, looked up and charged. Removed repeats appear in the initial bulk POST response as duplicates and duplicate_count; later batch results contain only retained numbers.
Is there a limit on how many numbers I can send?
One request may carry up to 100,000 numbers. There is no per-minute and no per-day limit. You may look up as much as your balance can pay for.
How do I get the results of a bulk request?
A bulk request answers straight away with a batch_id. Fetch the results with GET batch_id, or give us a notification_callback URL and we will POST the finished batch to your server.
How long does a bulk request take?
Numbers are handed to the provider in chunks of 500, a few seconds apart, and answers come back as they are ready. A batch waits up to 12 hours; anything still unanswered after that is marked failed and every rupee still held is returned.
Do different spellings of a number cost twice?
No. +923330189315, 923330189315, 00923330189315 and 03330189315 are all treated as one number, so they share one lookup and one cache entry.
Can I retry a bulk request safely?
Yes. Send it again with the same Idempotency-Key. Within 24 hours you get the first batch back instead of a second set of lookups, so nothing is charged twice.
Where should I keep my API key?
On your server only: in an environment variable or a config file outside your public folder. Never in an APK, an iOS app, browser JavaScript or a public GitHub repository.
Why did I receive IP_NOT_ALLOWED?
IP restriction is on for your account and the IP we saw is not on your list. The reply shows that IP in details.ip. Add it in your dashboard Profile page. If your server also uses IPv6, add that address too.
Why did I receive SERVICE_DISABLED?
The HLR/MNP lookup service is not switched on for your account yet. Contact SENDPK support and we will enable it.
Reference

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-Id so a repeated webhook is not handled twice.

Keeping the cost down

  • Use mnp unless 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 success first, then switch on error.code — never on the message text.
  • Treat mccmnc, mcc and mnc as text. Leading zeros matter.
  • Expect null: any network field may be missing when the operator will not publish it.
  • Route on current_network, never on original_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_id and batch_id in your logs for troubleshooting.
Reference

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.