<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ZelNum</title>
    <description>The latest articles on DEV Community by ZelNum (@zelnum).</description>
    <link>https://dev.to/zelnum</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4176627%2F61e63926-7ed7-4e93-a719-5e1befbcd4fe.png</url>
      <title>DEV Community: ZelNum</title>
      <link>https://dev.to/zelnum</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zelnum"/>
    <language>en</language>
    <item>
      <title>Bulk WhatsApp Checks: Async Jobs, Four States, and the Cost of a Wrong Answer</title>
      <dc:creator>ZelNum</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:17:37 +0000</pubDate>
      <link>https://dev.to/zelnum/bulk-whatsapp-checks-async-jobs-four-states-and-the-cost-of-a-wrong-answer-2f0o</link>
      <guid>https://dev.to/zelnum/bulk-whatsapp-checks-async-jobs-four-states-and-the-cost-of-a-wrong-answer-2f0o</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; 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 &lt;strong&gt;asynchronous&lt;/strong&gt;, the answer is &lt;strong&gt;four states rather than a boolean&lt;/strong&gt;, and the cost is &lt;strong&gt;deducted when you submit, not when the job finishes&lt;/strong&gt;. Get any of them wrong and the invoice is what tells you, months later.&lt;/p&gt;

&lt;p&gt;This is about the machinery around the check, not the check itself. The per-lookup price is the least interesting number in the system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The submit call returns a job, not an answer
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;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 }
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at &lt;code&gt;summary&lt;/code&gt;. It is populated on a response whose own &lt;code&gt;message&lt;/code&gt; reads "Task queued", which means the charge exists before a single number has been checked. Five thousand numbers at &lt;code&gt;0.5&lt;/code&gt; is the documented $1 per ten thousand rate, applied at submit.&lt;/p&gt;

&lt;p&gt;You are buying the attempt, not the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  A retried submit is a second charge
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The guard has to live on your side. Hash the batch before sending, store that hash next to the &lt;code&gt;task_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four states, not a boolean
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Segment&lt;/th&gt;
&lt;th&gt;Safe to treat as&lt;/th&gt;
&lt;th&gt;Why this is not the same as "no"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;registered&lt;/td&gt;
&lt;td&gt;present, usable&lt;/td&gt;
&lt;td&gt;The only segment that asserts anything positive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;inactive&lt;/td&gt;
&lt;td&gt;Not usable now, re-checkable later&lt;/td&gt;
&lt;td&gt;An account that is quiet is not an account that is gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;td&gt;Undecided, queue for another pass&lt;/td&gt;
&lt;td&gt;A lookup with no definite answer has not answered "no"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;review&lt;/td&gt;
&lt;td&gt;Undecided, hand to a human&lt;/td&gt;
&lt;td&gt;A held-back answer is not a rejection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bug this prevents is a one-liner, and it is common because it looks correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The documentation does not publish per-number response field names,&lt;/span&gt;
&lt;span class="c1"&gt;// so "state" stands in for whichever field carries the verdict.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onWhatsApp&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;registered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offWhatsApp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;registered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// swallows unknown and review&lt;/span&gt;

&lt;span class="c1"&gt;// what the four-way export is asking you to do instead&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usable&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;registered&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;held&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dropped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inactive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second filter in the first block is the whole problem. &lt;code&gt;unknown&lt;/code&gt; and &lt;code&gt;review&lt;/code&gt; are not negatives, but they are not &lt;code&gt;registered&lt;/code&gt; either, so a &lt;code&gt;!==&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rate limit is the real constraint
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Monthly volume&lt;/th&gt;
&lt;th&gt;At the documented rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Developer&lt;/td&gt;
&lt;td&gt;10K checks&lt;/td&gt;
&lt;td&gt;Under a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Growth&lt;/td&gt;
&lt;td&gt;1M checks&lt;/td&gt;
&lt;td&gt;About 100 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;10M+ checks&lt;/td&gt;
&lt;td&gt;About 17 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quiet check is still a shared service
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the receipt alongside the verdict
&lt;/h2&gt;

&lt;p&gt;A verdict has a shelf life. A number that reads &lt;code&gt;unknown&lt;/code&gt; in March can read &lt;code&gt;registered&lt;/code&gt; in June, and a number that was registered can be reassigned by December. A result without a timestamp is an assertion with no expiry.&lt;/p&gt;

&lt;p&gt;Four columns make a result auditable: the number in E.164, the state you received, the &lt;code&gt;task_id&lt;/code&gt; that produced it, and the UTC time of the lookup. The &lt;code&gt;task_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://zelnum.com/blog/phone-number-validation-possible-vs-valid/" rel="noopener noreferrer"&gt;invalid result is a claim about your data&lt;/a&gt;, and the fix is to record which data.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal batch shape
&lt;/h2&gt;

&lt;p&gt;Put together, the smallest pipeline that survives contact with real lists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fingerprint the batch, store the fingerprint, submit, and keep the returned &lt;code&gt;task_id&lt;/code&gt; next to it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Submit behind a token bucket sized to the documented rate.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Wait on the webhook, or poll the task result, then write all four states to their own column.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Re-queue &lt;code&gt;unknown&lt;/code&gt; on a schedule, route &lt;code&gt;review&lt;/code&gt; to a human, and keep &lt;code&gt;inactive&lt;/code&gt; out of the send.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reconcile charges against stored &lt;code&gt;task_id&lt;/code&gt;s rather than against your send log.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a one-off check, the &lt;a href="https://zelnum.com/whatsapp-number-checker/" rel="noopener noreferrer"&gt;WhatsApp number checker&lt;/a&gt; runs the lookup server-side and returns the state alongside the line type. For lists, the &lt;a href="https://zelnum.com/bulk-whatsapp-checker/" rel="noopener noreferrer"&gt;bulk WhatsApp checker&lt;/a&gt; takes CSV and TXT uploads, and the &lt;a href="https://zelnum.com/whatsapp-api/" rel="noopener noreferrer"&gt;WhatsApp API reference&lt;/a&gt; documents the detect, file and task-result endpoints, the rate, and the account-age product if campaign filtering needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is there a single-number endpoint, or is everything a batch?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "unknown" actually mean?
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;unknown&lt;/code&gt; produced no verdict, so it is not a negative. Treat it as work still owed and re-queue it instead of filtering it out.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does a one-million-number job take?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a WhatsApp Business account to run these checks?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I avoid paying twice for the same list?
&lt;/h3&gt;

&lt;p&gt;Fingerprint the batch before sending and store the fingerprint with the returned &lt;code&gt;task_id&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does the export split results into four lists instead of two?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/whatsapp-api/" rel="noopener noreferrer"&gt;WhatsApp checker API reference&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/whatsapp-number-checker/" rel="noopener noreferrer"&gt;WhatsApp number checker&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/bulk-whatsapp-checker/" rel="noopener noreferrer"&gt;Bulk WhatsApp checker&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/blog/how-to-filter-whatsapp-numbers-from-a-list/" rel="noopener noreferrer"&gt;How to filter WhatsApp numbers from a list&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/blog/phone-number-validation-possible-vs-valid/" rel="noopener noreferrer"&gt;Phone number validation: possible, valid and reachable&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>integration</category>
      <category>whatsapp</category>
    </item>
    <item>
      <title>Phone Number Validation: possible vs valid vs reachable</title>
      <dc:creator>ZelNum</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:17:33 +0000</pubDate>
      <link>https://dev.to/zelnum/phone-number-validation-possible-vs-valid-vs-reachable-2pk1</link>
      <guid>https://dev.to/zelnum/phone-number-validation-possible-vs-valid-vs-reachable-2pk1</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; when you ask "is this phone number valid?" you are actually asking three separate questions. &lt;strong&gt;Possible&lt;/strong&gt; means the digits fit the length range of the numbering plan. &lt;strong&gt;Valid&lt;/strong&gt; means the prefix matches a range a regulator has assigned. &lt;strong&gt;Reachable&lt;/strong&gt; means there is a live line on that number right now, and only a network lookup can answer it. A regex answers none of the three, and a &lt;code&gt;true&lt;/code&gt; from a validation library only means the library's dataset agrees with the prefix.&lt;/p&gt;

&lt;p&gt;Confusing these layers produces two failure modes that look nothing alike. Numbers that pass your check and then bounce forever, and real numbers that your own form rejects. Both cost money, and both become fixable once you know which layer is failing.&lt;/p&gt;




&lt;h2&gt;
  
  
  What each layer actually proves
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;th&gt;Typical tool&lt;/th&gt;
&lt;th&gt;What a &lt;code&gt;false&lt;/code&gt; usually means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Possible&lt;/td&gt;
&lt;td&gt;Do the digits fit the plan's length range?&lt;/td&gt;
&lt;td&gt;Length check, &lt;code&gt;isPossibleNumber()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Almost always a parse problem, not a bad number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Valid&lt;/td&gt;
&lt;td&gt;Is the prefix inside an assigned range?&lt;/td&gt;
&lt;td&gt;Prefix metadata, &lt;code&gt;isValidNumber()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Usually a dataset that predates the range&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Reachable&lt;/td&gt;
&lt;td&gt;Is a line allocated and in service?&lt;/td&gt;
&lt;td&gt;HLR or carrier lookup&lt;/td&gt;
&lt;td&gt;The number really is not in service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The order matters because each layer costs more than the one below it. Layer 1 is arithmetic. Layer 2 is a lookup in a file shipped inside your library. Layer 3 is a network round trip against carrier data, which is the only layer worth paying for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: "possible" is a length check and nothing else
&lt;/h2&gt;

&lt;p&gt;Most engineers carry an assumption that a phone number is 10 digits, or 11 once you add the country code. That assumption holds for the United States and almost nowhere else. Here are the length ranges recorded in the metadata of &lt;code&gt;phonenumbers&lt;/code&gt; 9.0.41, read directly from the library:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Country code&lt;/th&gt;
&lt;th&gt;Mobile lengths&lt;/th&gt;
&lt;th&gt;All lengths in the general descriptor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;United States&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canada&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;7, 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;United Kingdom&lt;/td&gt;
&lt;td&gt;+44&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;7, 9, 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;+49&lt;/td&gt;
&lt;td&gt;10, 11&lt;/td&gt;
&lt;td&gt;4 through 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;France&lt;/td&gt;
&lt;td&gt;+33&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Italy&lt;/td&gt;
&lt;td&gt;+39&lt;/td&gt;
&lt;td&gt;9, 10&lt;/td&gt;
&lt;td&gt;6 through 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spain&lt;/td&gt;
&lt;td&gt;+34&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netherlands&lt;/td&gt;
&lt;td&gt;+31&lt;/td&gt;
&lt;td&gt;9, 11&lt;/td&gt;
&lt;td&gt;5 through 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brazil&lt;/td&gt;
&lt;td&gt;+55&lt;/td&gt;
&lt;td&gt;10, 11&lt;/td&gt;
&lt;td&gt;8 through 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mexico&lt;/td&gt;
&lt;td&gt;+52&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;India&lt;/td&gt;
&lt;td&gt;+91&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;8 through 13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indonesia&lt;/td&gt;
&lt;td&gt;+62&lt;/td&gt;
&lt;td&gt;9 through 12&lt;/td&gt;
&lt;td&gt;7 through 17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nigeria&lt;/td&gt;
&lt;td&gt;+234&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10 through 14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Japan&lt;/td&gt;
&lt;td&gt;+81&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;8 through 17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Singapore&lt;/td&gt;
&lt;td&gt;+65&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;8, 10, 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Australia&lt;/td&gt;
&lt;td&gt;+61&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;5 through 12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mobile numbers cluster at 9 or 10 digits, but Indonesia spans 9 to 12 and the Netherlands ships both 9 and 11. The general descriptor is wider still, because it also covers service numbers, short codes and corporate ranges. Any pattern that demands a fixed digit count will reject valid numbers the moment your list leaves North America.&lt;/p&gt;

&lt;p&gt;This is what &lt;code&gt;isPossibleNumber()&lt;/code&gt; does. It counts digits and compares them against these ranges. Nothing about the prefix is consulted, which is why it stays fast and why it can only say "maybe".&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: "valid" is a prefix lookup against a dataset with a release date
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;isValidNumber()&lt;/code&gt; goes further and checks the prefix against ranges that the library ships as metadata. That metadata is a snapshot taken when the package was built. When a regulator assigns a new range, every installed copy keeps rejecting it until somebody upgrades.&lt;/p&gt;

&lt;p&gt;In January 2026 a thread on the libphonenumber discussion group reported three North American area codes, 357, 837 and 679, that &lt;code&gt;isValidNumber()&lt;/code&gt; flagged as invalid even though they had been allocated. A Google engineer replied in the same thread that the codes were assigned to their regions but not yet assigned to operators, and that anyone running metadata older than the update would see them fail.&lt;/p&gt;

&lt;p&gt;We reproduced it on both sides before writing this, against &lt;code&gt;phonenumbers&lt;/code&gt; 9.0.41 in Python and &lt;code&gt;libphonenumber-js&lt;/code&gt; 1.13.15 in JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// libphonenumber-js 1.13.15&lt;/span&gt;
&lt;span class="nf"&gt;parsePhoneNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+14155550123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// true  - 415 is an old assignment&lt;/span&gt;
&lt;span class="nf"&gt;parsePhoneNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+13575550100&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isPossible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nf"&gt;parsePhoneNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+13575550100&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// false - 2025 assignment, missing from metadata&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Number&lt;/th&gt;
&lt;th&gt;&lt;code&gt;isPossible()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;isValid()&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;+14155550123&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;Assigned long ago, present in the metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+13575550100&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;2025 allocation, absent from the metadata&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+18375550100&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Same cause&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+16795550100&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Same cause&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+10000000000&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Correct NANP shape, area code 000 does not exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+11234567890&lt;/td&gt;
&lt;td&gt;true&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Correct NANP shape, area code 123 is unassigned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+1234567&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;false&lt;/td&gt;
&lt;td&gt;Below the minimum length&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things follow from that table. A number can be possible and invalid at the same time, which is the whole difference between the two functions. And an invalid verdict is a statement about your dataset version, not about the number.&lt;/p&gt;

&lt;p&gt;The most common attempted fix is switching &lt;code&gt;libphonenumber-js&lt;/code&gt; from its default metadata to the full &lt;code&gt;libphonenumber-js/max&lt;/code&gt; build. It does not help. We ran both. The &lt;code&gt;max&lt;/code&gt; metadata adds precise patterns for ranges that already exist in the dataset, so it catches more malformed numbers inside known ranges. It cannot add a range the dataset never had. Upgrading the package is the only thing that clears a false negative caused by a new assignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: "reachable" is the only layer that talks to the network
&lt;/h2&gt;

&lt;p&gt;Layers 1 and 2 run offline against static data. Layer 3 asks the network itself whether the number is allocated, whether the line is in service, and what kind of line it is. That is the job of an HLR lookup or a carrier lookup, and it is the difference between "this number could exist" and "this number exists on a switch right now".&lt;/p&gt;

&lt;p&gt;Line type comes from this layer: mobile, fixed, VoIP, toll-free, pager. It is the field that decides whether an SMS can arrive at all, because a fixed line cannot receive one no matter how valid its prefix is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leading-zero trap
&lt;/h2&gt;

&lt;p&gt;National formats carry a trunk prefix in front of the number. International numbers do not. ITU-T E.164 defines the maximum length at 15 digits including the country code, and the trunk prefix is explicitly not part of the international number. Most libraries handle this for you when you parse with the right country context. Italy is the exception.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;As written nationally&lt;/th&gt;
&lt;th&gt;National significant number&lt;/th&gt;
&lt;th&gt;E.164&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;030 123456&lt;/td&gt;
&lt;td&gt;&lt;code&gt;30123456&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+4930123456&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;United Kingdom&lt;/td&gt;
&lt;td&gt;0121 234 5678&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1212345678&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+441212345678&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;France&lt;/td&gt;
&lt;td&gt;01 23 45 67 89&lt;/td&gt;
&lt;td&gt;&lt;code&gt;123456789&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+33123456789&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Italy&lt;/td&gt;
&lt;td&gt;02 1234 5678&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0212345678&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+390212345678&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Germany, the United Kingdom and France drop the zero. Italy keeps it, because the zero belongs to the subscriber number rather than to the dialling plan. A normaliser that runs &lt;code&gt;digits.replace(/^0/, "")&lt;/code&gt; will corrupt every Italian number it touches, and the corrupted value will often still pass a length check afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a regex is the right tool, and where it is not
&lt;/h2&gt;

&lt;p&gt;Regex is useful in exactly one place: cheap input hygiene. It can strip brackets and dashes, catch an obvious typo before a request is sent, and keep junk out of your database. What it cannot do is decide whether a number is real, because that answer does not exist in the string.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you want to do&lt;/th&gt;
&lt;th&gt;Right tool&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reject obviously mistyped input at the form&lt;/td&gt;
&lt;td&gt;Character and length check&lt;/td&gt;
&lt;td&gt;Cheap, forgiving, and does not block real numbers you cannot verify yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store numbers in one consistent form&lt;/td&gt;
&lt;td&gt;Parse to E.164 with a library&lt;/td&gt;
&lt;td&gt;Handles trunk prefixes and country context that a regex does not know about&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirm the prefix is assigned&lt;/td&gt;
&lt;td&gt;Prefix metadata, layer 2&lt;/td&gt;
&lt;td&gt;Requires the assigned-range tables, which change every year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decide whether to spend money on an SMS&lt;/td&gt;
&lt;td&gt;Network lookup, layer 3&lt;/td&gt;
&lt;td&gt;Only this layer knows whether the line can receive a message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which layer does your use case actually need
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Minimum layer&lt;/th&gt;
&lt;th&gt;Reasoning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Form validation before submit&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Stop typos, do not reject real numbers. Blocking a sign-up on layer 2 is how you lose customers who own valid numbers in recently opened ranges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleaning an imported CRM list&lt;/td&gt;
&lt;td&gt;2, then 3&lt;/td&gt;
&lt;td&gt;Layer 2 removes the bulk locally for free, then layer 3 is worth paying for on the remainder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sending SMS or OTP codes&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;A valid fixed line that cannot receive SMS burns credit and reads as a delivery failure downstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fraud and risk review&lt;/td&gt;
&lt;td&gt;3, plus line type&lt;/td&gt;
&lt;td&gt;VoIP, pager and non-fixed ranges behave differently from mobile ranges under the same geographies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;De-duplicating a database&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Two spellings of one number collapse into a single record once both are parsed to E.164&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What none of these layers can tell you
&lt;/h2&gt;

&lt;p&gt;Layer 3 tells you a line exists. It does not tell you who uses it, whether they want your message, or whether they will answer a call. Push any of these tools past that line and you are guessing.&lt;/p&gt;

&lt;p&gt;Number types also move. A fixed line can become a VoIP line after porting, and number portability means the range data that originally described a block does not always describe the network serving it today. A type field is a strong hint with a real error rate, not a fact about the future.&lt;/p&gt;

&lt;p&gt;The practical consequence for your logs: record the library version and the lookup timestamp next to every result. When a number you validated six months ago now fails, you need to know whether the number changed or your data did.&lt;/p&gt;

&lt;p&gt;Our own API sits at layer 3. It reads allocation, line type and reachability from carrier data, and it returns a reason code for each verdict. It cannot repair a number that was typed wrong, and it cannot tell you whether the person behind the line will respond. Anyone selling you that answer is selling you something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running this in bulk
&lt;/h2&gt;

&lt;p&gt;The ordering that survives real data is: normalise everything to E.164 first, run layer 2 locally to shrink the list, then send only the survivors to a network lookup. Doing it in that order means you pay for layer 3 only on numbers that already passed the free checks.&lt;/p&gt;

&lt;p&gt;For a single number, the free &lt;a href="https://zelnum.com/phone-validator/" rel="noopener noreferrer"&gt;phone validator&lt;/a&gt; runs all three layers and returns the line type alongside the verdict. For lists, &lt;a href="https://zelnum.com/blog/how-to-validate-phone-numbers-bulk/" rel="noopener noreferrer"&gt;bulk validation methods compared&lt;/a&gt; walks through the ordering and the cost trade-offs, the &lt;a href="https://zelnum.com/carrier-lookup-api/" rel="noopener noreferrer"&gt;carrier lookup API&lt;/a&gt; and &lt;a href="https://zelnum.com/hlr-lookup-api/" rel="noopener noreferrer"&gt;HLR lookup API&lt;/a&gt; pages document the layer 3 endpoints, and the &lt;a href="https://zelnum.com/blog/how-to-find-phone-carrier/" rel="noopener noreferrer"&gt;carrier data guide&lt;/a&gt; explains what each returned field means before you route on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does isValidNumber() return false for a real phone number?
&lt;/h3&gt;

&lt;p&gt;Usually because the library's metadata predates the range. New area codes, reopened ranges and new mobile prefixes are rejected until the package is upgraded. In January 2026 three North American codes, 357, 837 and 679, were still failing on current installs, and switching to larger metadata builds did not fix it. Upgrade the library, and log its version next to each result so stale data is visible instead of silent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a regex enough to validate a phone number?
&lt;/h3&gt;

&lt;p&gt;No. A regex can confirm that a string looks like a phone number and strip formatting. It cannot know which prefixes a country has assigned, and it cannot know whether a line exists. Every pattern strict enough to be useful will also reject real numbers from open numbering plans such as Germany or Japan.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between isPossibleNumber and isValidNumber?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;isPossibleNumber()&lt;/code&gt; compares the digit count against the length range for that country. &lt;code&gt;isValidNumber()&lt;/code&gt; also checks the prefix against assigned ranges. A number can be possible and invalid at the same time, which is exactly what happens to a correctly shaped number carrying an unassigned area code.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long can an international phone number be?
&lt;/h3&gt;

&lt;p&gt;ITU-T E.164 caps an international number at 15 digits including the country code. The national part is shorter and its length depends on the country: 9 or 10 digits for most mobile numbers, 8 in Singapore, up to 12 in Indonesia. That is why you cannot validate a national number on digit count alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do some countries keep a leading zero and others drop it?
&lt;/h3&gt;

&lt;p&gt;The leading zero is a national trunk prefix, and the trunk prefix is not part of the international number. Most countries drop it when converting to E.164. Italy keeps it because the zero belongs to the subscriber number itself. This is why hard-coded zero-stripping damages Italian data while leaving the value looking plausible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does phone number validation tell you anything about messaging apps?
&lt;/h3&gt;

&lt;p&gt;No. Messaging presence is a separate check against each platform, and the answer changes from week to week as accounts are created and deleted. Validation answers whether the line exists and what type it is. Those are different questions with different sources, and a tool that claims both in one call is usually bolting two systems together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/blog/how-to-validate-phone-numbers-bulk/" rel="noopener noreferrer"&gt;How to validate phone numbers in bulk: methods compared&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/blog/how-to-find-phone-carrier/" rel="noopener noreferrer"&gt;How to find a phone number's carrier&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/blog/how-to-validate-phone-numbers-step-by-step/" rel="noopener noreferrer"&gt;Phone number validation, step by step&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://zelnum.com/e164-phone-number-format/" rel="noopener noreferrer"&gt;E.164 phone number format&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>php</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
