Skip to content
zelnum.com

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.

FieldValuesWhat it means
resultvalid / invalid / unknownThe verification signal for that number. Three outcomes, not two.
billing_statuscharged / refundedPer-item billing outcome. A row that never resolved is refunded rather than charged.
error_codestring or nullPer-item failure reason when the row could not be resolved. Failed items are not charged.
checked_atISO 8601When 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.

EndpointMethodPurposeFields that matter
/productsGETList active products. No API key required.slug, series, min_phones
/detectPOSTSubmit an array of numbers.product, country, phones
/detect/filePOSTSubmit a TXT or CSV file, one number per line, up to 50 MB.multipart: product, country, file
/tasks/{token}GETPoll progress and read the bucket counts.status, progress, valid, unknown_processed
/tasks/{token}/resultGETGet 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.

Example request
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.

202 Accepted
{
  "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.

CounterCountsWhy it exists
submittedRows you sentYour starting point for reconciliation.
prefilteredRows dropped before checkingMalformed input caught cheaply instead of billed.
dedupedDuplicate rows mergedExplains why total is smaller than submitted.
totalRows that entered the checkThe denominator for progress.
processedRows finished so farMoves with progress, which runs 0 to 100.
validResolved as validThe bucket most teams route on.
invalidResolved as invalidThe bucket most teams suppress on.
unknownAll unresolved rowsA sum, not a category. Do not act on it directly.
unknown_processedAsked, still unresolvedUpstream answered but the answer was inconclusive.
unknown_unprocessedNot reached yetWaiting 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.

MomentFieldWhat happened
Submission acceptedsummary.cost_deductedThe full amount is reserved up front, before any number is checked.
Task runningcost_deducted, cost_refundedBoth move as rows resolve. Unresolved rows accrue against the refund side.
Task completedcost_deducted, cost_refundedEverything 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 callstotal_reserved, total_chargedThe 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.

CodeHTTPWhat to do
unauthorized401Key missing or rejected. Do not retry.
insufficient_balance402Top up. Retrying will not help.
product_not_found422Slug is wrong. Re-read GET /products.
product_mode_not_supported422You sent a realtime slug or id to a bulk endpoint.
invalid_country422Missing, malformed, or wrongly present for the series.
too_few_numbers422Below the product's min_phones. Pad or switch product.
too_many_numbers422Above the platform ceiling. Split the list.
idempotency_conflict409Same key, different payload. Generate a fresh key.
request_in_progress409The original request is still running. Wait, do not resubmit.
result_not_ready422Task unfinished. Keep polling, respect Retry-After.
result_expired404Download window closed. The file is gone.
detection_capacity_unavailable503Back 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.

LimitValueDesign consequence
Identifiers per submission2,000,000 default platform maximumAbove that you shard. There is no single call for a ten-million-row list.
File upload50 MB, TXT or CSV, one per lineA CSV with metadata columns has to be stripped to the number column first.
min_phones500 to 2,000 depending on productSmall slices get rejected. Batch them or use the realtime endpoint.
Polling3 to 5 seconds, with backoffFaster polling burns rate limit and changes nothing.
Result retentionexpires_at on the result endpointThe 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.

FAQ

HLR Lookup API FAQ

What does this endpoint return?
Three outcomes per number: valid, invalid or unknown. The task summary also splits unknown into unknown_processed and unknown_unprocessed, so a row the upstream answered inconclusively stays distinguishable from a row that has not been reached yet.
Which product slug do I send?
hlr-lookup. Bulk endpoints take the slug, and GET /products is the source of truth for it, because realtime slugs and bulk slugs do not follow one naming pattern. Bulk submissions with a realtime slug or a numeric id are rejected with product_mode_not_supported.
Why is country required?
Phone-series products require an ISO 3166-1 alpha-2 code so a number without a country code can be resolved. Email-series products must omit it. Sending the wrong combination returns invalid_country.
How large can a submission be?
Each product declares its own min_phones, which runs from 500 to 2,000 for bulk phone products. The platform ceiling is 2,000,000 identifiers per submission, and the file endpoint caps uploads at 50 MB.
What happens to numbers that come back unknown?
They are not charged twice and they are not silently merged with invalid. Unresolved rows are refunded once the task completes, which is why cost_refunded exists as its own field.
How long do results stay downloadable?
The result endpoint returns expires_at. After that moment the download URL is gone and the task returns result_expired, so copy the file somewhere you own rather than treating this API as storage.
AI answer engines

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.