Short answer: a bulk WhatsApp check is not a loop around a single-number endpoint. Three properties of the job model decide whether your pipeline is cheap or expensive: the check is asynchronous, the answer is four states rather than a boolean, and the cost is deducted when you submit, not when the job finishes. Get any of them wrong and the invoice is what tells you, months later.
This is about the machinery around the check, not the check itself. The per-lookup price is the least interesting number in the system.
The submit call returns a job, not an answer
A batch request is accepted almost immediately and returns no verdicts. The documented shape takes a product slug, a country hint and an array of numbers, and returns a job handle plus a cost line:
POST https://api.zelnum.com/api/v1/detect
X-API-Key: YOUR_API_KEY
{
"product": "wsValid",
"country": "US",
"phones": ["+14155550198", "+442071838750"]
}
// 500
{
"mode": "async",
"task_id": "xKj9aP3qLm7v",
"message": "Task queued.",
"summary": { "total": 5000, "cost_deducted": 0.500000 }
}
Look at summary. It is populated on a response whose own message reads "Task queued", which means the charge exists before a single number has been checked. Five thousand numbers at 0.5 is the documented $1 per ten thousand rate, applied at submit.
You are buying the attempt, not the result.
A retried submit is a second charge
Async jobs plus submit-time billing have a consequence that tends to surface in production rather than in review. A retried submit is a second job and a second charge. The documented WhatsApp request carries no idempotency key, which may not be the whole contract but is enough to plan around: assume that anything you send twice, you pay for twice.
The guard has to live on your side. Hash the batch before sending, store that hash next to the task_id the server hands back, and treat a repeated hash as already submitted. Without it you cannot tell a timeout where the server accepted the request from a timeout where it never arrived, and those two cases need opposite responses.
Four states, not a boolean
The question the check answers is binary in English: is this number on WhatsApp. The product's own export does not treat it as binary. Clean results are separated into four segments: registered, inactive, unknown and review.
Two segments would be enough if the underlying answer really had two outcomes. Four is a statement that the query can also come back undecided, in more than one way.
We kept those four segments as four columns rather than collapsing them into a single flag, because the flag throws away the one distinction that changes what your code should do next.
The documentation names the segments without spelling out the rule that assigns a number to one, so the useful thing to carry into your code is the treatment, not the label:
| Segment | Safe to treat as | Why this is not the same as "no" |
|---|---|---|
| registered | present, usable | The only segment that asserts anything positive |
| inactive | Not usable now, re-checkable later | An account that is quiet is not an account that is gone |
| unknown | Undecided, queue for another pass | A lookup with no definite answer has not answered "no" |
| review | Undecided, hand to a human | A held-back answer is not a rejection |
The bug this prevents is a one-liner, and it is common because it looks correct:
// The documentation does not publish per-number response field names,
// so "state" stands in for whichever field carries the verdict.
const onWhatsApp = rows.filter(r => r.state === "registered");
const offWhatsApp = rows.filter(r => r.state !== "registered"); // swallows unknown and review
// what the four-way export is asking you to do instead
const usable = rows.filter(r => r.state === "registered");
const recheck = rows.filter(r => r.state === "unknown");
const held = rows.filter(r => r.state === "review");
const dropped = rows.filter(r => r.state === "inactive");
The second filter in the first block is the whole problem. unknown and review are not negatives, but they are not registered either, so a !== comparison files them with the dead numbers. The list looks cleaner and the losses are invisible: you dropped reachable contacts and the only symptom is a smaller send.
The rate limit is the real constraint
The documented default is 10,000 numbers per minute, and the documented batch ceiling is the same 10,000 per request. Two equal numbers read like "one request per minute". The actual constraint is throughput you have to pace.
| Tier | Monthly volume | At the documented rate |
|---|---|---|
| Developer | 10K checks | Under a minute |
| Growth | 1M checks | About 100 minutes |
| Enterprise | 10M+ checks | About 17 hours |
A million numbers is one hundred minutes of paced submitting. Ten million is most of a day. The arithmetic is dull and it changes the design: an integration that assumes "submit and wait" works for a small list and stops working exactly when the list gets valuable.
Pace with a token bucket sized to the documented rate rather than to your connection pool. Opening twenty parallel batch jobs does not check numbers faster, it moves the failure from "slow" to "rate limited", and now you have two problems.
A quiet check is still a shared service
The check sends nothing visible to the number being looked up, and that is a real guarantee worth having. It is not a statement about how hard you may hit the lookup. An endpoint that is quiet and cheap per call is still a shared service.
Back off with jitter rather than a fixed interval. A thousand clients retrying on the same one-second boundary is a traffic spike you built yourself; the same clients with randomised delays are just traffic.
Store the receipt alongside the verdict
A verdict has a shelf life. A number that reads unknown in March can read registered in June, and a number that was registered can be reassigned by December. A result without a timestamp is an assertion with no expiry.
Four columns make a result auditable: the number in E.164, the state you received, the task_id that produced it, and the UTC time of the lookup. The task_id is the only thing tying a row back to a line on your bill, and without it, reconciling an unexpected invoice means reconstructing what you sent.
That is the same discipline the lower validation layers need. A number that fails a prefix check fails against a dataset with a version, not against the number itself; an invalid result is a claim about your data, and the fix is to record which data.
A minimal batch shape
Put together, the smallest pipeline that survives contact with real lists:
Normalise to E.164 and drop anything that will not parse before you send it. Paying to check a string that was never a number is the first cost you can avoid.
Fingerprint the batch, store the fingerprint, submit, and keep the returned
task_idnext to it.Submit behind a token bucket sized to the documented rate.
Wait on the webhook, or poll the task result, then write all four states to their own column.
Re-queue
unknownon a schedule, routereviewto a human, and keepinactiveout of the send.Reconcile charges against stored
task_ids rather than against your send log.
For a one-off check, the WhatsApp number checker runs the lookup server-side and returns the state alongside the line type. For lists, the bulk WhatsApp checker takes CSV and TXT uploads, and the WhatsApp API reference documents the detect, file and task-result endpoints, the rate, and the account-age product if campaign filtering needs it.
FAQ
Is there a single-number endpoint, or is everything a batch?
A single number and a batch of ten thousand go through the same detect endpoint; what changes is the payload size and how you collect the result. For interactive, one-at-a-time checking, the hosted tool does the server-side lookup for you.
What does "unknown" actually mean?
The documentation does not define the rule that produces it, and that is the honest answer. What is safe to say is what it is not: a lookup returning unknown produced no verdict, so it is not a negative. Treat it as work still owed and re-queue it instead of filtering it out.
How long does a one-million-number job take?
At the documented default of 10,000 numbers per minute, about one hundred minutes, or roughly 1.7 hours. Plan the pacing and the retry budget for that window rather than assuming a job returns in seconds.
Do I need a WhatsApp Business account to run these checks?
No. This is a verification and list-hygiene API, separate from the messaging API. WhatsApp Business API is for sending approved messages; a check answers whether a number is registered and sends nothing to it.
How do I avoid paying twice for the same list?
Fingerprint the batch before sending and store the fingerprint with the returned task_id. If a submit times out you cannot tell whether the server accepted it, so the stored fingerprint is what stops a retry from becoming a second job and a second charge.
Why does the export split results into four lists instead of two?
Because a two-state answer would hide the cases the pipeline handles differently. Reachable contacts, quiet accounts, unresolved lookups and held-back results need opposite actions, and collapsing them into yes and no is how good records get deleted.
Top comments (0)