# How to check if a phone number is on WhatsApp with an API

> How a WhatsApp registration check works, what registered, not registered and unknown mean, and how to call it responsibly from cURL, Node and Python.

Canonical: https://mobilevalidate.com/blog/check-if-a-number-is-on-whatsapp-api-guide · Last updated: 2026-09-25

![Cover: How to check if a phone number is on WhatsApp with an API](https://mobilevalidate.com/og/blog/check-if-a-number-is-on-whatsapp-api-guide.png)


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-09-25 · Category: Developers · Tags: WhatsApp, API, Channel selection, Consent, Developers

To check whether a phone number is on WhatsApp, send the number to a registration-check API and read back one of three answers: registered, not registered or unknown, each with a timestamp. With MobileValidate that is one `POST /v1/lookup` call with `checks: ["whatsapp"]`. This guide covers how such a check works, how to handle every answer in code, and how to use it responsibly.

## What does a WhatsApp registration check actually answer?

It answers one narrow question: is a WhatsApp account associated with this phone number right now? Nothing more. It doesn't say who owns the number, whether they read messages, or whether the phone is switched on.

WhatsApp ties each account to a phone number and verifies that number with a code at sign-up. So an account is a useful sign that the number was in use on a smartphone at some point. That makes the check valuable for two jobs:

- **Channel selection.** Before you send a passcode, a delivery update or a reminder the person asked for, you know whether WhatsApp is an option at all. See [channel selection](/use-cases/channel-selection).
- **A deliverability signal.** A number with an account is less likely to be a typo or a made-up entry. It is not proof of identity.

Because WhatsApp is so widely used ([WhatsApp announced two billion users in 2020](https://blog.whatsapp.com/two-billion-users-connecting-the-world-privately)), a "no" in a market where almost everyone uses it is informative. A "no" in a market where other apps dominate says much less. The [channel-by-country guide](/blog/choosing-a-messaging-channel-by-country) covers that.

## How does a registration check work, conceptually?

Messaging apps need a way for users to find friends. When you install one, it can compare the numbers in your address book with its account directory and show which contacts use the app. This is called **contact discovery**. A registration check asks the same kind of question for a single number: does the directory hold an account for it?

The answer comes back as one of three states:

| State | Meaning | Billed? |
|---|---|---|
| `registered: true` | An account exists for the number | Yes |
| `registered: false` | A conclusive answer: no account | Yes |
| `registered: null` | No conclusive answer (timeout, service unavailable, unsupported country) | No |

Two design rules follow from this. First, **unknown is never a no**. A timeout says nothing about the number, so the API reports `null` and the `reason`. You're not charged for inconclusive results (unknown, unsupported country, timeout, invalid, duplicate). Second, **every answer is dated**. `checked_at` says when the answer was obtained. Accounts come and go, and numbers get reassigned by carriers, so an answer from last quarter is weaker than one from this morning.

We don't describe the upstream mechanics in more detail, and you shouldn't need them. Your integration depends only on the three states, the timestamp and the reason codes.

## How do I make my first request?

Use a test key (`mv_test_…`). Test keys are free, never reach a real network and return fixed answers for the numbers in the reserved range `+44 7700 900000–900999`. `…001` is registered, `…002` is not, `…003` is unknown and `…004` stays pending for about five seconds. See [test mode](/docs/test-mode).

```bash
curl https://api.mobilevalidate.com/v1/lookup \
  -H "Authorization: Bearer $MOBILEVALIDATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"numbers": ["+447700900001", "+447700900002", "+447700900003", "+447700900004"],
       "checks": ["whatsapp"], "wait": 2}'
```

With a short `wait` of 2 seconds, the request returns before `…004` is done. This is real test-mode output (the `summary`, `next` and the four check results):

```json
{
  "id": "lkp_0VWFWKcBPOs4UJIn7CeW",
  "status": "pending",
  "next": {"poll_url": "/v1/lookups/lkp_0VWFWKcBPOs4UJIn7CeW", "poll_after_ms": 2000},
  "summary": {"total": 4, "registered": 1, "not_registered": 1, "unknown": 1, "pending": 1,
              "invalid": 0, "suppressed": 0}
}
```

```json
{"service": "whatsapp.registered", "status": "completed", "registered": true,  "confidence": "high", "checked_at": "2026-09-25T16:15:04.516Z", "billed": false, "reason": null}
{"service": "whatsapp.registered", "status": "completed", "registered": false, "confidence": "high", "checked_at": "2026-09-25T16:15:04.516Z", "billed": false, "reason": null}
{"service": "whatsapp.registered", "status": "unknown",   "registered": null,  "confidence": null,   "checked_at": null, "billed": false, "reason": "UPSTREAM_TIMEOUT"}
{"service": "whatsapp.registered", "status": "pending",   "registered": null,  "confidence": null,   "checked_at": null, "billed": false, "reason": null, "poll_after_ms": 2000}
```

(Excerpt: some fields are trimmed. `billed` is `false` here because test keys never bill.)

## How do I handle pending answers?

Most answers arrive within the default 10-second `wait`. The rest come back as `pending`, and the whole lookup has `status: "pending"` plus a `next.poll_url`. You have two options:

1. **Poll.** `GET /v1/lookups/{id}?wait=10` long-polls (`wait` can be up to 30 seconds) and returns as soon as the lookup completes. Respect `poll_after_ms` between polls.
2. **Webhook.** Pass a verified `webhook_endpoint_id` and receive `lookup.completed`. See [webhooks](/docs/webhooks).

Polling the lookup above five seconds later returned `"status": "completed"`, and the fourth result became:

```json
{"service": "whatsapp.registered", "status": "completed", "registered": true,
 "confidence": "high", "confidence_score": 0.99, "checked_at": "2026-09-25T16:15:11.607Z",
 "cached": false, "age_seconds": 0, "billed": false, "reason": null, "poll_after_ms": null}
```

In a user-facing flow such as sign-up, don't block on a pending answer. Continue with your default channel and use the result for the next message.

## What does the code look like in Node and Python?

Both examples send numbers in the POST body (never in the URL), poll while the lookup is pending, and map each answer to a channel decision.

**Node.js / TypeScript (built-in `fetch`, Node 18+):**

```ts
const API = "https://api.mobilevalidate.com";
const headers = {
  Authorization: `Bearer ${process.env.MOBILEVALIDATE_API_KEY}`,
  "Content-Type": "application/json",
};

type Check = { status: string; registered: boolean | null; reason: string | null; checked_at: string | null };

export async function whatsappStatus(numbers: string[]) {
  let res = await fetch(`${API}/v1/lookup`, {
    method: "POST", headers,
    body: JSON.stringify({ numbers, checks: ["whatsapp"], wait: 10 }),
  });
  let lookup = await res.json();
  while (lookup.status === "pending") {
    await new Promise((r) => setTimeout(r, lookup.next?.poll_after_ms ?? 2000));
    res = await fetch(`${API}/v1/lookups/${lookup.id}?wait=10`, { headers });
    lookup = await res.json();
  }
  return lookup.results.map((r: any) => {
    const c: Check | undefined = r.checks?.["whatsapp.registered"];
    const channel = c?.registered === true ? "whatsapp" : "sms"; // null (unknown) falls back, never blocks
    return { e164: r.e164, number_status: r.number_status, registered: c?.registered ?? null,
             reason: c?.reason ?? null, checked_at: c?.checked_at ?? null, channel };
  });
}
```

**Python (`requests`):**

```python
import os, time, requests

API = "https://api.mobilevalidate.com"
H = {"Authorization": f"Bearer {os.environ['MOBILEVALIDATE_API_KEY']}"}

def whatsapp_status(numbers, default_country=None):
    body = {"numbers": numbers, "checks": ["whatsapp"], "wait": 10}
    if default_country:
        body["default_country"] = default_country  # needed for national formats like "07700 900001"
    lookup = requests.post(f"{API}/v1/lookup", json=body, headers=H, timeout=40).json()
    while lookup.get("status") == "pending":
        time.sleep((lookup.get("next") or {}).get("poll_after_ms", 2000) / 1000)
        lookup = requests.get(f"{API}/v1/lookups/{lookup['id']}", params={"wait": 10},
                              headers=H, timeout=40).json()
    out = []
    for r in lookup["results"]:
        c = (r.get("checks") or {}).get("whatsapp.registered") or {}
        out.append({"e164": r.get("e164"), "number_status": r["number_status"],
                    "registered": c.get("registered"), "reason": c.get("reason"),
                    "checked_at": c.get("checked_at")})
    return out
```

Production code should also handle HTTP errors: `429 rate_limited` (retry after `Retry-After`), `402 insufficient_balance`, and `403 suspected_enumeration`. The [errors reference](/docs/errors) lists them all.

## How should my application act on each answer?

Store the answer with its timestamp and decide per message type. A reasonable starting table:

| Answer | Sign-up / OTP | Order updates the customer opted into | CRM record |
|---|---|---|---|
| `registered: true` | Offer WhatsApp as a delivery option; keep SMS as fallback | Send on WhatsApp if the customer chose it | Store `true` + `checked_at` |
| `registered: false` | Go straight to SMS or voice | Use SMS or e-mail | Store `false` + `checked_at` |
| `registered: null` (`unknown`) | Use your default channel | Keep the previous routing | Don't overwrite a stored answer |
| `number_status: invalid_number` | Ask the user to correct the number | Fix the record | Flag for cleanup |
| `number_status: suppressed` | Use your default channel | Use your default channel | Don't check again |

Two practical rules. **Never overwrite a conclusive stored answer with `null`.** A timeout today doesn't erase what you learned last week. And **refresh on a schedule**, for example monthly, or when a WhatsApp delivery fails. Repeat checks inside the freshness window are served from your account's cache for free (`cached: true, billed: false`).

## When should I use the WhatsApp Business check instead?

Request `whatsapp.business` when you need to know whether the account is a business account, for example to tell a supplier's support line apart from a personal number in a B2B CRM. It answers both questions at once. If a request asks for both `whatsapp` and `whatsapp.business`, they collapse into one `whatsapp.business` result.

Real test-mode output for `+447700900006` (registered, business) and `+447700900002` (no account):

```json
{"whatsapp.business": {"service": "whatsapp.business", "status": "completed", "registered": true,
  "attributes": {"business": true}, "confidence": "high", "checked_at": "2026-09-25T16:15:11.650Z", "billed": false}}
{"whatsapp.business": {"service": "whatsapp.business", "status": "completed", "registered": false,
  "attributes": {"business": false}, "confidence": "high", "checked_at": "2026-09-25T16:15:11.650Z", "billed": false}}
```

The older top-level `whatsapp` object mirrors this result with a `business` field, so integrations written for the first API version keep working. In real time the `business` flag may be `null` even when `registered` is conclusive. See the [WhatsApp Business check](/services/whatsapp-business-check).

## What are the limits, and why do they exist?

| Limit | Value |
|---|---|
| Numbers (and e-mails) per real-time lookup | 100 |
| Identifiers per bulk job | 50,000 |
| Checks per request | 20 |
| Identifiers × checks | 2,000 per lookup, 100,000 per job |
| Request rate per key | 10 per second, burst 20 |
| Consecutive numbers in one request | 19 at most; 20 or more → `403 suspected_enumeration` |

The last rule deserves an explanation. Contact discovery can be abused to map who uses an app. In 2021, researchers showed they could query [10% of US mobile numbers against WhatsApp and 100% against Signal](https://eprint.iacr.org/2020/1119) with modest resources. In 2025, researchers from the University of Vienna and SBA Research reported that WhatsApp's contact discovery allowed the [enumeration of 3.5 billion accounts](https://www.univie.ac.at/en/news/press-room/press-releases/detail/researchers-discover-security-vulnerability-in-whatsapp), which Meta has since mitigated. A check API must not become a shortcut for that kind of mapping. So we refuse sequential ranges, cap daily volume per account, and return only yes/no/unknown, never names, photos or profiles. See [rate limits and abuse](/docs/rate-limits-and-abuse).

## How do I use the check responsibly?

The number usually belongs to a person, so the check is likely to be processing of personal data. Three rules help you stay within data-protection law and platform policy (for your specific case, consult your own counsel):

1. **Check only numbers you have a lawful reason to process**: customers, sign-ups, and leads who gave you their number. Under the [GDPR](https://eur-lex.europa.eu/eli/reg/2016/679/oj) you need a lawful basis (Article 6), and data minimisation (Article 5(1)(c)) suggests keeping only what you need: the answer and its date, not the whole response.
2. **Use the answer to choose a channel, not to start conversations.** The [WhatsApp Business Messaging Policy](https://business.whatsapp.com/policy) says businesses may only contact people who gave them their number and opted in to receive messages. A `true` answer is not consent.
3. **Respect objections.** People can object through our [opt-out form](/opt-out). Suppressed numbers are skipped and never charged, and you should honour objections in your own systems too.

Our [acceptable use policy](/legal/acceptable-use) forbids unsolicited bulk messaging, building profiles of individuals and checking ranges of numbers to find out who uses WhatsApp.

## What are the key takeaways?

- A WhatsApp check returns **registered, not registered or unknown**, always with `checked_at`. Only conclusive answers are billed.
- **Unknown is missing data.** Fall back to your default channel and never overwrite a stored conclusive answer with it.
- Use a short `wait` in user-facing flows and **poll `GET /v1/lookups/{id}`** or use a webhook for pending answers.
- Request **`whatsapp.business`** when you also need the business flag.
- The check is for **numbers you already hold** and for picking a channel for expected messages. Sequential ranges are refused by design.
- Build and test every branch for free with the [test numbers](/docs/test-mode), then read the [WhatsApp check](/services/whatsapp-number-check) page for pricing and coverage.

## Sources

1. [WhatsApp Business Messaging Policy](https://business.whatsapp.com/policy) — WhatsApp, 2026
2. [Researchers discover security vulnerability in WhatsApp](https://www.univie.ac.at/en/news/press-room/press-releases/detail/researchers-discover-security-vulnerability-in-whatsapp) — University of Vienna, 2025
3. [All the Numbers are US: Large-scale Abuse of Contact Discovery in Mobile Messengers (NDSS 2021)](https://eprint.iacr.org/2020/1119) — IACR ePrint / NDSS, 2021
4. [Two Billion Users: Connecting the World Privately](https://blog.whatsapp.com/two-billion-users-connecting-the-world-privately) — WhatsApp, 2020
5. [General Data Protection Regulation (EU) 2016/679](https://eur-lex.europa.eu/eli/reg/2016/679/oj) — European Union, 2016

## Frequently asked questions

### Does checking a number send a WhatsApp message or notify the person?

No. The check answers whether an account is associated with the number. No message is sent, and nothing appears on the person's phone. No name, photo or profile is returned.

### What should my code do when the answer is unknown?

Treat unknown (registered: null) as missing data, not as a no. Keep your default channel, such as SMS, and check again later. Unknown answers are not charged.

### Why was my request refused with suspected_enumeration?

The request contained 20 or more consecutive numbers. Checking number ranges is how people try to build lists of platform users, so the API refuses it. Check only numbers you already hold, such as customers and sign-ups.

### Can I use the result to start WhatsApp conversations with new contacts?

No. WhatsApp's business messaging policy requires that people gave you their number and opted in to receive messages from you. Use the check to pick a channel for messages people already expect.

### How do I find out whether the account is a WhatsApp Business account?

Request whatsapp.business instead of whatsapp. It answers both questions: whether an account exists, and in the business attribute whether it is a business account. In real time the business flag may be null.
