HLR Lookup API
An asynchronous endpoint for checking phone numbers against network data in bulk. You submit a list, poll a task token, and download a result file.
Each number comes back valid, invalid or unknown. The task summary counts every bucket separately, including the two halves of unknown, and the charge is reconciled against what was actually resolved.
What this endpoint returns, and what it does not
Worth reading before you wire anything up: the shape of the answer decides how you handle each row.
| Field | Values | What it means |
|---|---|---|
result | valid / invalid / unknown | The verification signal for that number. Three outcomes, not two. |
billing_status | charged / refunded | Per-item billing outcome. A row that never resolved is refunded rather than charged. |
error_code | string or null | Per-item failure reason when the row could not be resolved. Failed items are not charged. |
checked_at | ISO 8601 | When this row was resolved. Store it: a result without a timestamp ages into a guess. |
What you get
A verdict per number, a billing outcome per number, a timestamp, and a summary that reconciles submitted, filtered, deduplicated, processed and unresolved rows.
What you do not get
No subscriber identity, no live device location, and no roaming detail. This endpoint answers whether a number resolves, not who holds it or where the handset is.
That second box matters more than it looks. A team expecting carrier porting history will build a routing rule on a field that is not in the response, then conclude the API is broken. It is answering a different question.
Five calls, in this order
The contract is small on purpose. Read the catalog, submit, poll, collect, then copy the file.
| Endpoint | Method | Purpose | Fields that matter |
|---|---|---|---|
/products | GET | List active products. No API key required. | slug, series, min_phones |
/detect | POST | Submit an array of numbers. | product, country, phones |
/detect/file | POST | Submit a TXT or CSV file, one number per line, up to 50 MB. | multipart: product, country, file |
/tasks/{token} | GET | Poll progress and read the bucket counts. | status, progress, valid, unknown_processed |
/tasks/{token}/result | GET | Get the download URL and its expiry. | download_url, expires_at |
Polling interval is worth a sentence. The contract recommends 3 to 5 seconds with backoff. Hammering the status endpoint every 200 milliseconds does not make the job finish sooner; it just spends requests that the X-RateLimit-Remaining header is counting on every reply.
A request that actually runs
Two fields cause almost every rejected submission: the slug and the country code.
curl -X POST "https://api.zelnum.com/api/v1/detect" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"product":"hlr-lookup","country":"US","phones":["+14155550198","+14155550199"]}'
product
The bulk slug is hlr-lookup. Not the product id, not the page URL. Read GET /products instead of hard-coding it, because realtime and bulk slugs do not share one naming rule.
country
ISO 3166-1 alpha-2, required for every phone-series product, and required to be absent for email. A national-format number cannot be resolved without it.
phones
An array of E.164 strings. The submission must meet the product's min_phones, which ranges from 500 to 2,000 for bulk phone products.
What comes back when the task is accepted
A 202 with a token, not a result. The money is reserved here, before anything has been checked.
{
"mode": "async",
"task_id": "task_00XyZ...",
"message": "Task queued",
"summary": { "total": 2, "cost_deducted": 0 },
"idempotent_replay": false,
"recovery_pending": false
}
The cost_deducted value above is a placeholder; the real figure depends on your contract rate and is deducted at submission time. Two flags on this envelope are worth wiring into an alert: idempotent_replay means your retry reused an earlier accepted request instead of creating a second task, and recovery_pending means the platform is still reconciling an interrupted submission. Treating a recovered submission as a failed one is how a list gets processed twice and paid for once.
The ten counters, and why the last two are the useful ones
This is the part of the response most bulk APIs collapse into two numbers. Keep them apart.
| Counter | Counts | Why it exists |
|---|---|---|
submitted | Rows you sent | Your starting point for reconciliation. |
prefiltered | Rows dropped before checking | Malformed input caught cheaply instead of billed. |
deduped | Duplicate rows merged | Explains why total is smaller than submitted. |
total | Rows that entered the check | The denominator for progress. |
processed | Rows finished so far | Moves with progress, which runs 0 to 100. |
valid | Resolved as valid | The bucket most teams route on. |
invalid | Resolved as invalid | The bucket most teams suppress on. |
unknown | All unresolved rows | A sum, not a category. Do not act on it directly. |
unknown_processed | Asked, still unresolved | Upstream answered but the answer was inconclusive. |
unknown_unprocessed | Not reached yet | Waiting in the queue. Not an answer at all. |
The split between the last two counters is the one that saves a list. If you poll a running task and read a single unknown figure, half of that number is simply work that has not started. Suppress on it and you have deleted rows you paid to check and never got an answer for. Wait until unknown_unprocessed reaches zero, then treat what is left in unknown_processed as the genuine inconclusive set.
The other three counters answer the question every reconciliation meeting eventually asks: why is the processed count lower than the row count in the file I uploaded? prefiltered and deduped are the answer, and they are separate fields so you can show the arithmetic instead of asserting it.
Where the money actually goes
Reservation happens on submit. Reconciliation happens on completion. The two are not the same number.
| Moment | Field | What happened |
|---|---|---|
| Submission accepted | summary.cost_deducted | The full amount is reserved up front, before any number is checked. |
| Task running | cost_deducted, cost_refunded | Both move as rows resolve. Unresolved rows accrue against the refund side. |
| Task completed | cost_deducted, cost_refunded | Everything that never resolved is returned. The net charge is cost_deducted minus cost_refunded; the bulk flow does not expose a separate reserved total. |
| Realtime calls | total_reserved, total_charged | The synchronous endpoints state the same accounting explicitly, and total_reserved = total_charged + total_refunded holds per request. Do not carry that field name over to the bulk status response, where it does not exist. |
Two consequences worth designing around. A cached realtime result is still billed, and the response says so with cached: true, so do not build a cost model that assumes cache hits are free. And a failed row is not charged at all, which means the cheapest way to handle a flaky upstream is to let it fail rather than to retry blindly and pay twice.
Error codes worth branching on
The code is a stable enum. The message is a courtesy for humans and can change without notice.
| Code | HTTP | What to do |
|---|---|---|
unauthorized | 401 | Key missing or rejected. Do not retry. |
insufficient_balance | 402 | Top up. Retrying will not help. |
product_not_found | 422 | Slug is wrong. Re-read GET /products. |
product_mode_not_supported | 422 | You sent a realtime slug or id to a bulk endpoint. |
invalid_country | 422 | Missing, malformed, or wrongly present for the series. |
too_few_numbers | 422 | Below the product's min_phones. Pad or switch product. |
too_many_numbers | 422 | Above the platform ceiling. Split the list. |
idempotency_conflict | 409 | Same key, different payload. Generate a fresh key. |
request_in_progress | 409 | The original request is still running. Wait, do not resubmit. |
result_not_ready | 422 | Task unfinished. Keep polling, respect Retry-After. |
result_expired | 404 | Download window closed. The file is gone. |
detection_capacity_unavailable | 503 | Back off with jitter, then retry. Not your payload. |
Branching on HTTP status alone loses information here. Several distinct problems share 422, and the difference between too_few_numbers and invalid_country decides whether you pad the file or fix the header. The full enum is in the OpenAPI spec, including the eight capacity and routing codes that all surface as 503.
The limits that decide your architecture
These are contract values, not marketing approximations.
| Limit | Value | Design consequence |
|---|---|---|
| Identifiers per submission | 2,000,000 default platform maximum | Above that you shard. There is no single call for a ten-million-row list. |
| File upload | 50 MB, TXT or CSV, one per line | A CSV with metadata columns has to be stripped to the number column first. |
min_phones | 500 to 2,000 depending on product | Small slices get rejected. Batch them or use the realtime endpoint. |
| Polling | 3 to 5 seconds, with backoff | Faster polling burns rate limit and changes nothing. |
| Result retention | expires_at on the result endpoint | The API is not your archive. Copy the file out when it lands. |
The retention row surprises people. A job that finished on Monday may be undownloadable by the following week, and the only signal is a 404 with result_expired. If your pipeline treats the task token as a permanent result handle, it will fail quietly on any backfill run older than the retention window.
What the answer cannot support
Three limits belong to the data itself. None of them are bugs waiting for a fix.
unknown is not invalid
An inconclusive answer means the upstream did not resolve the row. Merging it into invalid suppresses records that may be perfectly good.
A verdict is not an identity
Nothing here tells you who holds the number. Attaching a verdict to a person requires data this endpoint does not have.
A verdict ages
Numbers get reassigned and ported. checked_at is what makes a stored result defensible six months later.
HLR Lookup API FAQ
What does this endpoint return?
Which product slug do I send?
Why is country required?
How large can a submission be?
What happens to numbers that come back unknown?
How long do results stay downloadable?
Request an AI summary of ZelNum
Open ZelNum in your preferred AI assistant to review its bulk WhatsApp, Telegram, phone validation, carrier lookup, social account, crypto exchange, CSV upload, and API workflows.
The prompt points assistants to ZelNum.com, live pricing, product pages, and API documentation.