1. Submit
POST the array, or upload a file. The full amount is reserved at this moment, before a single number is queried.
Send a phone list, get back which network each number sits on. One asynchronous job, results as a file you can join straight back to your own records.
Need a product explanation for ported carrier routing? See Number Portability Check.
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", "+442071838750"]
}'
HTTP 202. Nothing has been looked up yet, and the balance is already reserved. The carrier data arrives through the download.
Four moves, and the first one is the only one that costs you anything up front. The carrier answers arrive at the end, not in the submit response.
POST the array, or upload a file. The full amount is reserved at this moment, before a single number is queried.
HTTP 202 with a task_id. This is a receipt, not a result. Treat the submit response as the end of the submission, not the end of the work.
The status call returns a progress percentage and the counters. Rows resolve in batches, so partial answers are real answers for the rows that landed.
Ask the result endpoint for a download URL, then copy the file. The link expires, and an expired link is gone rather than merely slow.
This product will not accept a short list. If you have 40 numbers to enrich, this endpoint is the wrong tool and the single lookup is the right one. If you have 400,000, the floor is irrelevant and the rest of this section is what matters.
| Counter | What it counts | What you do with it |
|---|---|---|
| submitted | Rows you sent | Compare against your own input count first. A mismatch means the parser, not the lookup, is the problem. |
| prefiltered | Rows dropped before any query | Duplicate and malformed input lands here. A high number means your source list is dirty, which is worth fixing upstream. |
| deduped | Repeats removed | Free reduction in cost if you deduplicate locally first, and a signal when a CRM export repeats the same number across records. |
| total | Rows actually accepted | This is the number the reservation is computed from, so it is the one to reconcile your invoice against. |
country, and keep the country in your join key.
The download holds one row per accepted number. Read the header row rather than assuming a column order: field names are stable per product, positions are not a contract.
Which network the number belongs to, the line type when one is reported, and the outcome of the lookup itself. That is the entire product: a routing decision per row, derived from numbering data rather than from anything you told us.
Whether the person behind the number is reachable, whether they consented, whether the handset is online, or who they are. A carrier result is not an identity check and it is not a deliverability guarantee.
Portability is the reason this page cannot promise permanence. A number keeps its digits when the subscriber changes network, so a carrier result describes the day you ran it, not the rest of the list's life.
A ported number is indistinguishable from a stable one by looking at it. Only a fresh query settles where it currently routes.
Store the lookup timestamp beside the carrier. A routing rule that reads a two-year-old carrier without knowing its age is guessing with a straight face.
Refreshing the whole database on a schedule is expensive. Refreshing the numbers you are about to message is not.
Two pages go deeper on this: why carrier data goes stale for the mechanics of portability, and number portability check for the non-developer version.
Carrier enrichment is only worth the money if a rule reads it. These three are the ones that survive contact with real traffic.
VoIP and landline numbers rarely belong in an SMS campaign. Drop them before the send rather than paying for delivery attempts that cannot succeed. The VoIP checker explains the line-type signals.
Carrier is the field that decides which route a message takes, and route quality differs. Group the list by carrier and send through the path that performs for that network.
Transactional traffic deserves a fresh lookup; a quarterly newsletter does not. Spending $4 per 10,000 rows on the segment that matters is a rounding error against one failed send.
For the longer version, see carrier lookup for SMS routing and carrier lookup use cases.
Prices live in the pricing catalog, and this page reads them from there rather than restating a number that can drift. HLR Lookup costs $4 per 10,000 rows with a minimum batch of 2,000.
| List size | Reserved at submit | What the shape tells you |
|---|---|---|
| 2,000 | $0.80 | The floor. Paying the minimum is the honest answer for a small test. |
| 10,000 | $4.00 | One unit price. Compare vendor quotes on this row before negotiating anything larger. |
| 50,000 | $20.00 | The size where deduplicating locally starts paying for itself. |
| 250,000 | $100.00 | Above the 50 MB file ceiling if the numbers are long, so expect to split into two jobs. |
| 1,000,000 | $400.00 | Half the platform maximum. Run it per country and reconcile each job separately. |
The reservation is not the invoice.
Bulk jobs reserve against the accepted row count up front, then refund what never got checked. The status call exposes cost_deducted and cost_refunded, so the net charge is a subtraction you can audit instead of a number you have to trust.
A quote endpoint exists, with a caveat.
There is a bulk quote call for pre-flighting a large list without reserving balance, and its own documentation still describes the request body as incomplete. Do not wire it into a cost model until the shape is settled; estimate from the table above instead.
Both forms create the same kind of task and return the same envelope. The difference is where the list has to live before it is submitted.
Good up to a few tens of thousands of rows, when you already hold the numbers in memory. Numbers go in international format and the country is a separate field.
POST https://api.zelnum.com/api/v1/detect
X-API-Key: YOUR_API_KEY
{
"product": "hlr-lookup",
"country": "US",
"phones": ["+14155550198", "+14155550132"]
}
Multipart upload for anything large: a TXT or CSV with one number per line, capped at 50 MB. This is the form that survives a million-row export without a memory spike.
curl -X POST "https://api.zelnum.com/api/v1/detect/file" \
-H "X-API-Key: YOUR_API_KEY" \
-F "product=hlr-lookup" \
-F "country=US" \
-F "[email protected]"
The rate limit shapes the client as much as the list does.
Submissions are limited to 10 per minute per API key, reads to 60 per minute. A poll loop that checks twice a second spends the entire read budget on one job and gets a 429 with a Retry-After header for its trouble. Poll once a second at most, and read Retry-After instead of inventing a backoff.
Most of these share HTTP 422, which is why the status code alone will send you looking in the wrong place. Branch on code. The message field is written for humans and is not stable.
| code | HTTP | What it means |
|---|---|---|
| product_not_found | 422 | The slug is not in the catalog. Read GET /v1/products instead of copying a slug out of a tutorial, including this one. |
| product_mode_not_supported | 422 | A realtime product was sent to the bulk endpoint, or the reverse. Bulk endpoints take slugs; realtime endpoints take numeric ids. |
| invalid_country | 422 | Missing, or not an ISO 3166-1 alpha-2 code. Phone products require it; this product is a phone product. |
| too_few_numbers | 422 | Below the product floor, which is 2,000 here. Add rows or use the single lookup. |
| result_expired | 404 | The file aged out. Note that this returns 404, not 410, so an existence check that treats 404 as "wrong task id" will misread it. |
idempotency_conflict at 409 when the same key arrives with a different body, and detection_capacity_unavailable at 503 when the queue is full. The second one is temporary, and retrying it later is correct rather than abusive.
This is the single most common integration bug we see on the bulk flow, and it does not throw.
summary.cost_deducted is a JSON number. Arithmetic works, and so does a strict comparison.
cost_deducted and cost_refunded are strings, because they are money and money should not round-trip through a float. Parse them as decimals before subtracting.
"4.0" - "1.2" is fine and "4.0" + "1.2" is not, so a reconciliation job that adds two refunds together produces "1.21.2" and writes it to your billing table without complaint. Convert once at the boundary, then keep decimals throughout.
Twilio is the safer choice if you already run messaging there, because carrier data and sending sit behind one account. This is the better fit when the job is bulk enrichment of a list you already own, priced per row and returned as a file.
Read the Twilio alternative| Category | zelnum.com | Twilio Lookup API |
|---|---|---|
| Unit of work | A list, 2,000 rows minimum | One number per request |
| Bulk handling | Native task: submit, poll, download a file | Your loop, your concurrency, your retries |
| Result shape | Downloadable file, one row per number | JSON per call |
| Fits when | The list exists already and needs enriching | The lookup sits inline in a messaging flow |
Being precise about this is cheaper than a support ticket. There is one official client, and everything else is an HTTP call you can make from any language in about twenty lines.
Version 0.1.1, installable from PyPI as zelnum. Use it if your enrichment runs as an offline job, which is the usual shape.
pip install zelnum
No published packages for Node, Ruby or PHP. The API is a single POST plus a poll, so the honest recommendation is curl or your own HTTP client rather than waiting for a client library.
curl -X POST https://api.zelnum.com/api/v1/detect ...
The questions that come up before the first production job.
country field with an ISO 3166-1 alpha-2 code. A list that spans countries becomes one job per country.cost_deducted and cost_refunded so you can see the net.Idempotency-Key header. A repeat submission returns the original task with idempotent_replay set, instead of creating a second job and reserving the amount twice. This is the cheapest protection against a retry after a timeout.result_expires_at. After that the download returns result_expired with HTTP 404, which is easy to misread as a wrong task id. Copy the file into your own storage the moment the task completes rather than fetching it on demand weeks later.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.