mobilevalidate-sdk 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mobilevalidate_sdk-1.0.0/.gitignore +7 -0
- mobilevalidate_sdk-1.0.0/LICENSE +21 -0
- mobilevalidate_sdk-1.0.0/PKG-INFO +339 -0
- mobilevalidate_sdk-1.0.0/README.md +304 -0
- mobilevalidate_sdk-1.0.0/pyproject.toml +53 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/__init__.py +65 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/_client.py +612 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/_constants.py +42 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/_errors.py +364 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/_http.py +285 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/py.typed +0 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/types.py +166 -0
- mobilevalidate_sdk-1.0.0/src/mobilevalidate/webhooks.py +103 -0
- mobilevalidate_sdk-1.0.0/tests/conftest.py +92 -0
- mobilevalidate_sdk-1.0.0/tests/test_async.py +90 -0
- mobilevalidate_sdk-1.0.0/tests/test_client.py +328 -0
- mobilevalidate_sdk-1.0.0/tests/test_constants.py +41 -0
- mobilevalidate_sdk-1.0.0/tests/test_webhooks.py +84 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BroadNet Technologies Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mobilevalidate-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MobileValidate API client: check phone numbers on WhatsApp, Telegram, Viber and more, carrier lookup, spam reputation and e-mail verification. Sync + async, typed.
|
|
5
|
+
Project-URL: Homepage, https://mobilevalidate.com/docs/sdk
|
|
6
|
+
Project-URL: Documentation, https://mobilevalidate.com/docs
|
|
7
|
+
Project-URL: Issues, https://mobilevalidate.com/contact
|
|
8
|
+
Author: MobileValidate (BroadNet Technologies Inc.)
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api client,carrier lookup,email verification,hlr,line type,phone number,phone validation,sdk,spam reputation,telegram,viber,whatsapp check
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Communications :: Telephony
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: httpx<1,>=0.24
|
|
28
|
+
Requires-Dist: typing-extensions>=4.1; python_version < '3.11'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: build>=1; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
33
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# mobilevalidate (Python)
|
|
37
|
+
|
|
38
|
+
Official Python client for the [MobileValidate](https://mobilevalidate.com) API — *know before you send*. Check
|
|
39
|
+
whether phone numbers are registered on WhatsApp, Telegram, Viber and many other services, look up carrier and line
|
|
40
|
+
type, get a report-based spam reputation, and verify e-mail addresses — several checks in one request.
|
|
41
|
+
|
|
42
|
+
Every answer is `registered: True | False | None` (or, for data services such as the carrier lookup or spam
|
|
43
|
+
reputation, a whitelisted set of `attributes`). `None` means **unknown**, and unknown answers are never billed. Each
|
|
44
|
+
answer also carries `confidence`, `checked_at`, `cached` and `billed`.
|
|
45
|
+
|
|
46
|
+
- **Sync and async** clients (`MobileValidate`, `AsyncMobileValidate`) on [httpx](https://www.python-httpx.org/).
|
|
47
|
+
- **Typed**: TypedDicts for every response, `py.typed`, one exception class per API error code. No pydantic.
|
|
48
|
+
- **Safe retries**: every POST gets an automatic `Idempotency-Key`; retryable errors (429, 5xx, network, timeouts)
|
|
49
|
+
are retried twice with jittered exponential backoff, honouring `Retry-After`.
|
|
50
|
+
- **Waits for slow answers**: `lookup()` long-polls until the lookup completes or the wait budget runs out.
|
|
51
|
+
- **Webhook verification** (Standard Webhooks) with the standard library only.
|
|
52
|
+
- Python 3.9 or later.
|
|
53
|
+
|
|
54
|
+
Docs: <https://mobilevalidate.com/docs/sdk> · API reference: <https://mobilevalidate.com/docs/api-reference> ·
|
|
55
|
+
Test values: <https://mobilevalidate.com/docs/test-values>
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install mobilevalidate-sdk
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 30-second quickstart (no signup)
|
|
64
|
+
|
|
65
|
+
The public **sandbox key** is built in. It answers only the documented test values below, so you can run this as
|
|
66
|
+
pasted:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from mobilevalidate import MobileValidate
|
|
70
|
+
|
|
71
|
+
mv = MobileValidate(sandbox=True)
|
|
72
|
+
|
|
73
|
+
lookup = mv.lookup(["+447700900001", "+447700900002", "+447700900003"], checks=["whatsapp"])
|
|
74
|
+
for row in lookup["results"]:
|
|
75
|
+
answer = row["checks"]["whatsapp.registered"]
|
|
76
|
+
print(row["e164"], answer["registered"], answer["status"])
|
|
77
|
+
# +447700900001 True completed
|
|
78
|
+
# +447700900002 False completed
|
|
79
|
+
# +447700900003 None unknown
|
|
80
|
+
print(lookup.request_id)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then use your own key: set `MOBILEVALIDATE_API_KEY` and drop `sandbox=True`.
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
mv = MobileValidate() # reads MOBILEVALIDATE_API_KEY (and MOBILEVALIDATE_BASE_URL if set)
|
|
87
|
+
answer = mv.lookup("+447700900001", checks=["whatsapp"])["results"][0]["checks"]["whatsapp.registered"]
|
|
88
|
+
if answer["registered"] is True:
|
|
89
|
+
... # send on WhatsApp
|
|
90
|
+
elif answer["registered"] is False:
|
|
91
|
+
... # fall back to SMS
|
|
92
|
+
else:
|
|
93
|
+
... # unknown: answer["status"] / answer["reason"] explain why; not billed
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Keys and test mode
|
|
97
|
+
|
|
98
|
+
| Key | Where from | What it answers |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| Sandbox key (`MobileValidate(sandbox=True)`) | built into the SDK; public by design | Only the test values below. Anything else is refused with `SandboxMagicOnlyError`. Limited per IP (30/min, 1,000/day), bulk jobs up to 10 rows, no webhooks. |
|
|
101
|
+
| Personal test key `mv_test_…` | [Get a test key](https://mobilevalidate.com/get-test-key) | The test values below exactly as documented; **any other number or address gets a fake but stable answer** (the same input always gives the same result). Bulk jobs and webhooks work. |
|
|
102
|
+
| Live key `mv_live_…` | after your access request is approved | Real checks, billed per conclusive answer. |
|
|
103
|
+
|
|
104
|
+
Test mode (sandbox and personal test keys) never reaches any network and is never billed.
|
|
105
|
+
|
|
106
|
+
### Test numbers
|
|
107
|
+
|
|
108
|
+
| Number | Result |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `+447700900001` | registered |
|
|
111
|
+
| `+447700900002` | not registered |
|
|
112
|
+
| `+447700900003` | unknown (`registered: None`, `reason: UPSTREAM_TIMEOUT`) |
|
|
113
|
+
| `+447700900004` | pending for about 5 s, then registered (shows the auto-wait) |
|
|
114
|
+
| `+447700900005` | `unsupported_country` |
|
|
115
|
+
| `+447700900006` | registered, business account |
|
|
116
|
+
| `+447700900429` / `+447700900402` | request fails with `RateLimitedError` / `InsufficientBalanceError` |
|
|
117
|
+
|
|
118
|
+
### Test e-mail addresses
|
|
119
|
+
|
|
120
|
+
| Address | Result |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `registered@test.mobilevalidate.com` | registered |
|
|
123
|
+
| `not-registered@test.mobilevalidate.com` | not registered |
|
|
124
|
+
| `unknown@test.mobilevalidate.com` | unknown (`reason: UPSTREAM_TIMEOUT`) |
|
|
125
|
+
| `pending@test.mobilevalidate.com` | pending for about 5 s, then registered |
|
|
126
|
+
| `unsupported@test.mobilevalidate.com` | unknown (`reason: UNSUPPORTED_PROVIDER`) |
|
|
127
|
+
| `rate-limited@…` / `no-balance@…` | request fails with `RateLimitedError` / `InsufficientBalanceError` |
|
|
128
|
+
|
|
129
|
+
The same values are available in code: `mobilevalidate.TEST_NUMBERS["registered"]`, `TEST_NUMBERS["not_registered"]`, … and `mobilevalidate.TEST_EMAILS["registered"]` (the same keys as the Node SDK, in snake_case).
|
|
130
|
+
|
|
131
|
+
## Several checks, numbers and e-mails
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
lookup = mv.lookup(
|
|
135
|
+
["+447700900001"],
|
|
136
|
+
emails=["registered@test.mobilevalidate.com"],
|
|
137
|
+
checks=["whatsapp", "telegram", "carrier", "email"],
|
|
138
|
+
)
|
|
139
|
+
for row in lookup["results"]: # rows: numbers first, then e-mails
|
|
140
|
+
if row.get("kind") == "email":
|
|
141
|
+
print(row["email"], row["checks"]["email.valid"]["registered"])
|
|
142
|
+
else:
|
|
143
|
+
print(row["e164"], row["checks"]["telegram.registered"]["registered"], row["checks"]["network.carrier"]["attributes"])
|
|
144
|
+
print(lookup["summary"].get("by_service"))
|
|
145
|
+
catalog = mv.services() # what your key can use, with prices
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Phone checks run on numbers, e-mail checks on e-mails (≤ 100 in total per lookup). Invalid rows carry
|
|
149
|
+
`number_status` / `email_status` and, when the API can tell, a plain-English `suggestion` (for example a missing
|
|
150
|
+
country code). E-mail answers are yes / no / unknown only — never names, photos or profiles.
|
|
151
|
+
|
|
152
|
+
Options: `checks`, `default_country` (ISO alpha-2 for national-format numbers), `max_age` (seconds or `"30m"`,
|
|
153
|
+
`"24h"`, `"7d"`; `0` forces a fresh, billed check), `wait` (seconds the server waits, 0–30, default 10; `0` returns
|
|
154
|
+
immediately), `wait_timeout` (overall polling budget, default 60 s), `max_cost` (`"0.05"` — refuses the request if it
|
|
155
|
+
could cost more), `metadata`, `webhook_endpoint_id`, `idempotency_key`, `timeout`, `max_retries`.
|
|
156
|
+
|
|
157
|
+
Money is always a decimal string: `{"amount": "0.0012", "currency": "USD"}`.
|
|
158
|
+
|
|
159
|
+
## Bulk jobs
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
est = mv.jobs.estimate(numbers=numbers, checks=["whatsapp"])
|
|
163
|
+
print(est["max_cost"]["amount"])
|
|
164
|
+
job = mv.jobs.create(numbers=numbers, checks=["whatsapp"], max_cost=est["max_cost"]["amount"])
|
|
165
|
+
job = mv.jobs.wait(job["id"], wait_timeout=600) # long-polls until completed / failed / cancelled
|
|
166
|
+
for row in mv.jobs.results(job["id"], registered=True): # follows every cursor page for you
|
|
167
|
+
print(row["e164"])
|
|
168
|
+
csv_text = mv.jobs.download(job["id"]) # the whole file as text (CSV, or format="ndjson")
|
|
169
|
+
mv.jobs.download_to(job["id"], "results.csv") # streams to disk; returns the bytes written
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`jobs.results_page()` returns a single page; `jobs.get(id, wait=30)` and `jobs.cancel(id)` are also available.
|
|
173
|
+
|
|
174
|
+
## Errors
|
|
175
|
+
|
|
176
|
+
Every failure raises a subclass of `MobileValidateError` with `code`, `message`, `status`, `retryable`,
|
|
177
|
+
`request_id`, `param`, `doc_url`, `suggestion` and `retry_after`:
|
|
178
|
+
|
|
179
|
+
```python
|
|
180
|
+
from mobilevalidate import MobileValidate, MobileValidateError, InsufficientBalanceError, SandboxMagicOnlyError
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
mv.lookup("+447700900402", checks=["whatsapp"])
|
|
184
|
+
except InsufficientBalanceError as e:
|
|
185
|
+
print("top up:", e.message, e.request_id)
|
|
186
|
+
except SandboxMagicOnlyError as e:
|
|
187
|
+
print(e.suggestion) # how to fix it, in plain English
|
|
188
|
+
except MobileValidateError as e:
|
|
189
|
+
print(e.code, e.status, e.message, e.suggestion, e.doc_url, e.request_id)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
| Class | Code | HTTP |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `InvalidRequestError` | `invalid_request` | 400 |
|
|
195
|
+
| `TooManyNumbersError` | `too_many_numbers` | 400 |
|
|
196
|
+
| `TestNumberOnlyError` | `test_number_only` | 400 |
|
|
197
|
+
| `InvalidCursorError` | `invalid_cursor` | 400 |
|
|
198
|
+
| `UnauthorizedError` (alias `AuthenticationError`) | `unauthorized` | 401 |
|
|
199
|
+
| `InsufficientBalanceError` | `insufficient_balance` | 402 |
|
|
200
|
+
| `CostLimitExceededError` | `cost_limit_exceeded` | 402 |
|
|
201
|
+
| `InsufficientScopeError` | `insufficient_scope` | 403 |
|
|
202
|
+
| `ServiceDisabledError` | `service_disabled` | 403 |
|
|
203
|
+
| `SandboxMagicOnlyError` | `sandbox_magic_only` | 403 |
|
|
204
|
+
| `SuspectedEnumerationError` | `suspected_enumeration` | 403 |
|
|
205
|
+
| `NotFoundError` | `not_found` | 404 |
|
|
206
|
+
| `IdempotencyKeyReusedError` | `idempotency_key_reused` | 409 |
|
|
207
|
+
| `IdempotencyRequestInProgressError` | `idempotency_request_in_progress` | 409 (retried) |
|
|
208
|
+
| `TestKeyExistsError` | `test_key_exists` | 409 |
|
|
209
|
+
| `PayloadTooLargeError` | `payload_too_large` | 413 |
|
|
210
|
+
| `RateLimitedError` (alias `RateLimitError`) | `rate_limited` | 429 (retried) |
|
|
211
|
+
| `DailyCapReachedError` | `daily_cap_reached` | 429 (retried) |
|
|
212
|
+
| `SpendCapReachedError` | `spend_cap_reached` | 429 |
|
|
213
|
+
| `InternalServerError` | `internal_error` | 500 (retried) |
|
|
214
|
+
| `TemporarilyUnavailableError` | `temporarily_unavailable` | 503 (retried) |
|
|
215
|
+
| `APIError` | any other API code | — |
|
|
216
|
+
| `APIConnectionError` / `APITimeoutError` | `connection_error` / `timeout` | — (retried) |
|
|
217
|
+
| `MissingApiKeyError`, `InvalidArgumentError`, `InvalidResponseError` | SDK-side | — |
|
|
218
|
+
|
|
219
|
+
All API error classes derive from `APIError`. Per-number problems (invalid, duplicate, unknown) are not errors; they
|
|
220
|
+
appear in each row's `number_status` / `email_status` and `checks[code]["status"]`.
|
|
221
|
+
|
|
222
|
+
## Retries and timeouts
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
mv = MobileValidate(
|
|
226
|
+
timeout=30.0, # seconds per HTTP request; server long-poll time is added automatically
|
|
227
|
+
max_retries=2, # retryable errors only; 0 disables
|
|
228
|
+
wait_timeout=60.0, # overall polling budget for lookup()
|
|
229
|
+
)
|
|
230
|
+
mv.lookup("+447700900001", timeout=5, max_retries=0) # per-call overrides
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Retries use full-jitter exponential backoff (0.5 s, 1 s, … capped at 8 s) or the server's `Retry-After`. A
|
|
234
|
+
`Retry-After` above 60 s (for example a daily cap) is raised instead of waited for. The same `Idempotency-Key` is
|
|
235
|
+
sent on every attempt, so a retried POST is never charged twice. Every returned object has `.request_id` — quote it
|
|
236
|
+
when you contact support.
|
|
237
|
+
|
|
238
|
+
You can pass your own `httpx.Client` / `httpx.AsyncClient` (`http_client=`) for proxies or custom transports; the SDK
|
|
239
|
+
does not close clients it did not create. Use the client as a context manager to close its connection pool.
|
|
240
|
+
|
|
241
|
+
## Async
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
import asyncio
|
|
245
|
+
from mobilevalidate import AsyncMobileValidate
|
|
246
|
+
|
|
247
|
+
async def main():
|
|
248
|
+
async with AsyncMobileValidate(sandbox=True) as mv:
|
|
249
|
+
lookup = await mv.lookup("+447700900004", checks=["whatsapp"]) # waits ~5 s for the pending answer
|
|
250
|
+
print(lookup["status"], lookup["results"][0]["checks"]["whatsapp.registered"]["registered"])
|
|
251
|
+
async for row in mv.jobs.results("job_..."):
|
|
252
|
+
...
|
|
253
|
+
|
|
254
|
+
asyncio.run(main())
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Webhooks
|
|
258
|
+
|
|
259
|
+
Verify every webhook on the **raw** request body. `verify_webhook` checks an HMAC-SHA256 over
|
|
260
|
+
`{webhook-id}.{webhook-timestamp}.{body}` against `webhook-signature: v1,<base64>` (several signatures are allowed
|
|
261
|
+
during secret rotation), in constant time, with 5 minutes of clock tolerance. Secrets look like `whsec_<base64>`.
|
|
262
|
+
|
|
263
|
+
Flask:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
import os
|
|
267
|
+
from flask import Flask, request
|
|
268
|
+
from mobilevalidate import verify_webhook, WebhookVerificationError
|
|
269
|
+
|
|
270
|
+
app = Flask(__name__)
|
|
271
|
+
|
|
272
|
+
@app.post("/webhooks/mobilevalidate")
|
|
273
|
+
def webhook():
|
|
274
|
+
try:
|
|
275
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["MOBILEVALIDATE_WEBHOOK_SECRET"])
|
|
276
|
+
except WebhookVerificationError:
|
|
277
|
+
return "", 400
|
|
278
|
+
if event["type"] == "job.completed":
|
|
279
|
+
job_id = event["data"]["id"] # fetch rows with mv.jobs.results(job_id)
|
|
280
|
+
return "", 204
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
FastAPI:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
import os
|
|
287
|
+
from fastapi import FastAPI, Request, Response
|
|
288
|
+
from mobilevalidate import verify_webhook, WebhookVerificationError
|
|
289
|
+
|
|
290
|
+
app = FastAPI()
|
|
291
|
+
|
|
292
|
+
@app.post("/webhooks/mobilevalidate")
|
|
293
|
+
async def webhook(request: Request):
|
|
294
|
+
try:
|
|
295
|
+
event = verify_webhook(await request.body(), request.headers, os.environ["MOBILEVALIDATE_WEBHOOK_SECRET"])
|
|
296
|
+
except WebhookVerificationError:
|
|
297
|
+
return Response(status_code=400)
|
|
298
|
+
return Response(status_code=204)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Events: `lookup.completed`, `job.completed`, `job.failed`, `job.progress`, `balance.low`, `limits.cap_reached`.
|
|
302
|
+
Payloads never contain phone numbers; fetch results with your key. `sign_webhook(secret, msg_id, timestamp, body)`
|
|
303
|
+
produces a valid signature for testing your own receiver.
|
|
304
|
+
|
|
305
|
+
## Other methods
|
|
306
|
+
|
|
307
|
+
| Method | API |
|
|
308
|
+
|---|---|
|
|
309
|
+
| `lookups.get(id, wait=0)` | `GET /v1/lookups/{id}` |
|
|
310
|
+
| `services()` | `GET /v1/services` |
|
|
311
|
+
| `account.get()`, `limits.get()` | `GET /v1/account`, `GET /v1/limits` |
|
|
312
|
+
| `usage.get(from_date="2026-09-01", to_date="2026-09-30", group_by="day")` | `GET /v1/usage` |
|
|
313
|
+
| `webhook_endpoints.create(url=..., events=[...])`, `.list()`, `.delete(id)`, `.test(id)` | `/v1/webhook_endpoints` |
|
|
314
|
+
| `webhooks.verify(raw_body, headers, secret)` | same as `verify_webhook` |
|
|
315
|
+
|
|
316
|
+
## Security notes
|
|
317
|
+
|
|
318
|
+
- Keep live keys server-side; pass them via `MOBILEVALIDATE_API_KEY`, not in source code.
|
|
319
|
+
- Never log full phone numbers or e-mail addresses; mask them (for example `+44•••••••01`).
|
|
320
|
+
- Only check numbers and addresses you have a legitimate relationship with. Runs of consecutive numbers or
|
|
321
|
+
digit-variant addresses are refused (`SuspectedEnumerationError`).
|
|
322
|
+
- Use `max_cost` to cap what a single request may spend.
|
|
323
|
+
|
|
324
|
+
## Versioning
|
|
325
|
+
|
|
326
|
+
Semantic versioning. The SDK targets API `/v1`; responses may gain fields and enums may gain values within `/v1`, so
|
|
327
|
+
tolerate values you don't know (responses are plain dicts and keep unknown fields).
|
|
328
|
+
|
|
329
|
+
## Development
|
|
330
|
+
|
|
331
|
+
```bash
|
|
332
|
+
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
333
|
+
.venv/bin/pytest
|
|
334
|
+
.venv/bin/python -m build # sdist + wheel in dist/
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## License
|
|
338
|
+
|
|
339
|
+
MIT © 2026 BroadNet Technologies Inc. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# mobilevalidate (Python)
|
|
2
|
+
|
|
3
|
+
Official Python client for the [MobileValidate](https://mobilevalidate.com) API — *know before you send*. Check
|
|
4
|
+
whether phone numbers are registered on WhatsApp, Telegram, Viber and many other services, look up carrier and line
|
|
5
|
+
type, get a report-based spam reputation, and verify e-mail addresses — several checks in one request.
|
|
6
|
+
|
|
7
|
+
Every answer is `registered: True | False | None` (or, for data services such as the carrier lookup or spam
|
|
8
|
+
reputation, a whitelisted set of `attributes`). `None` means **unknown**, and unknown answers are never billed. Each
|
|
9
|
+
answer also carries `confidence`, `checked_at`, `cached` and `billed`.
|
|
10
|
+
|
|
11
|
+
- **Sync and async** clients (`MobileValidate`, `AsyncMobileValidate`) on [httpx](https://www.python-httpx.org/).
|
|
12
|
+
- **Typed**: TypedDicts for every response, `py.typed`, one exception class per API error code. No pydantic.
|
|
13
|
+
- **Safe retries**: every POST gets an automatic `Idempotency-Key`; retryable errors (429, 5xx, network, timeouts)
|
|
14
|
+
are retried twice with jittered exponential backoff, honouring `Retry-After`.
|
|
15
|
+
- **Waits for slow answers**: `lookup()` long-polls until the lookup completes or the wait budget runs out.
|
|
16
|
+
- **Webhook verification** (Standard Webhooks) with the standard library only.
|
|
17
|
+
- Python 3.9 or later.
|
|
18
|
+
|
|
19
|
+
Docs: <https://mobilevalidate.com/docs/sdk> · API reference: <https://mobilevalidate.com/docs/api-reference> ·
|
|
20
|
+
Test values: <https://mobilevalidate.com/docs/test-values>
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install mobilevalidate-sdk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 30-second quickstart (no signup)
|
|
29
|
+
|
|
30
|
+
The public **sandbox key** is built in. It answers only the documented test values below, so you can run this as
|
|
31
|
+
pasted:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from mobilevalidate import MobileValidate
|
|
35
|
+
|
|
36
|
+
mv = MobileValidate(sandbox=True)
|
|
37
|
+
|
|
38
|
+
lookup = mv.lookup(["+447700900001", "+447700900002", "+447700900003"], checks=["whatsapp"])
|
|
39
|
+
for row in lookup["results"]:
|
|
40
|
+
answer = row["checks"]["whatsapp.registered"]
|
|
41
|
+
print(row["e164"], answer["registered"], answer["status"])
|
|
42
|
+
# +447700900001 True completed
|
|
43
|
+
# +447700900002 False completed
|
|
44
|
+
# +447700900003 None unknown
|
|
45
|
+
print(lookup.request_id)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then use your own key: set `MOBILEVALIDATE_API_KEY` and drop `sandbox=True`.
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
mv = MobileValidate() # reads MOBILEVALIDATE_API_KEY (and MOBILEVALIDATE_BASE_URL if set)
|
|
52
|
+
answer = mv.lookup("+447700900001", checks=["whatsapp"])["results"][0]["checks"]["whatsapp.registered"]
|
|
53
|
+
if answer["registered"] is True:
|
|
54
|
+
... # send on WhatsApp
|
|
55
|
+
elif answer["registered"] is False:
|
|
56
|
+
... # fall back to SMS
|
|
57
|
+
else:
|
|
58
|
+
... # unknown: answer["status"] / answer["reason"] explain why; not billed
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Keys and test mode
|
|
62
|
+
|
|
63
|
+
| Key | Where from | What it answers |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| Sandbox key (`MobileValidate(sandbox=True)`) | built into the SDK; public by design | Only the test values below. Anything else is refused with `SandboxMagicOnlyError`. Limited per IP (30/min, 1,000/day), bulk jobs up to 10 rows, no webhooks. |
|
|
66
|
+
| Personal test key `mv_test_…` | [Get a test key](https://mobilevalidate.com/get-test-key) | The test values below exactly as documented; **any other number or address gets a fake but stable answer** (the same input always gives the same result). Bulk jobs and webhooks work. |
|
|
67
|
+
| Live key `mv_live_…` | after your access request is approved | Real checks, billed per conclusive answer. |
|
|
68
|
+
|
|
69
|
+
Test mode (sandbox and personal test keys) never reaches any network and is never billed.
|
|
70
|
+
|
|
71
|
+
### Test numbers
|
|
72
|
+
|
|
73
|
+
| Number | Result |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `+447700900001` | registered |
|
|
76
|
+
| `+447700900002` | not registered |
|
|
77
|
+
| `+447700900003` | unknown (`registered: None`, `reason: UPSTREAM_TIMEOUT`) |
|
|
78
|
+
| `+447700900004` | pending for about 5 s, then registered (shows the auto-wait) |
|
|
79
|
+
| `+447700900005` | `unsupported_country` |
|
|
80
|
+
| `+447700900006` | registered, business account |
|
|
81
|
+
| `+447700900429` / `+447700900402` | request fails with `RateLimitedError` / `InsufficientBalanceError` |
|
|
82
|
+
|
|
83
|
+
### Test e-mail addresses
|
|
84
|
+
|
|
85
|
+
| Address | Result |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `registered@test.mobilevalidate.com` | registered |
|
|
88
|
+
| `not-registered@test.mobilevalidate.com` | not registered |
|
|
89
|
+
| `unknown@test.mobilevalidate.com` | unknown (`reason: UPSTREAM_TIMEOUT`) |
|
|
90
|
+
| `pending@test.mobilevalidate.com` | pending for about 5 s, then registered |
|
|
91
|
+
| `unsupported@test.mobilevalidate.com` | unknown (`reason: UNSUPPORTED_PROVIDER`) |
|
|
92
|
+
| `rate-limited@…` / `no-balance@…` | request fails with `RateLimitedError` / `InsufficientBalanceError` |
|
|
93
|
+
|
|
94
|
+
The same values are available in code: `mobilevalidate.TEST_NUMBERS["registered"]`, `TEST_NUMBERS["not_registered"]`, … and `mobilevalidate.TEST_EMAILS["registered"]` (the same keys as the Node SDK, in snake_case).
|
|
95
|
+
|
|
96
|
+
## Several checks, numbers and e-mails
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
lookup = mv.lookup(
|
|
100
|
+
["+447700900001"],
|
|
101
|
+
emails=["registered@test.mobilevalidate.com"],
|
|
102
|
+
checks=["whatsapp", "telegram", "carrier", "email"],
|
|
103
|
+
)
|
|
104
|
+
for row in lookup["results"]: # rows: numbers first, then e-mails
|
|
105
|
+
if row.get("kind") == "email":
|
|
106
|
+
print(row["email"], row["checks"]["email.valid"]["registered"])
|
|
107
|
+
else:
|
|
108
|
+
print(row["e164"], row["checks"]["telegram.registered"]["registered"], row["checks"]["network.carrier"]["attributes"])
|
|
109
|
+
print(lookup["summary"].get("by_service"))
|
|
110
|
+
catalog = mv.services() # what your key can use, with prices
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Phone checks run on numbers, e-mail checks on e-mails (≤ 100 in total per lookup). Invalid rows carry
|
|
114
|
+
`number_status` / `email_status` and, when the API can tell, a plain-English `suggestion` (for example a missing
|
|
115
|
+
country code). E-mail answers are yes / no / unknown only — never names, photos or profiles.
|
|
116
|
+
|
|
117
|
+
Options: `checks`, `default_country` (ISO alpha-2 for national-format numbers), `max_age` (seconds or `"30m"`,
|
|
118
|
+
`"24h"`, `"7d"`; `0` forces a fresh, billed check), `wait` (seconds the server waits, 0–30, default 10; `0` returns
|
|
119
|
+
immediately), `wait_timeout` (overall polling budget, default 60 s), `max_cost` (`"0.05"` — refuses the request if it
|
|
120
|
+
could cost more), `metadata`, `webhook_endpoint_id`, `idempotency_key`, `timeout`, `max_retries`.
|
|
121
|
+
|
|
122
|
+
Money is always a decimal string: `{"amount": "0.0012", "currency": "USD"}`.
|
|
123
|
+
|
|
124
|
+
## Bulk jobs
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
est = mv.jobs.estimate(numbers=numbers, checks=["whatsapp"])
|
|
128
|
+
print(est["max_cost"]["amount"])
|
|
129
|
+
job = mv.jobs.create(numbers=numbers, checks=["whatsapp"], max_cost=est["max_cost"]["amount"])
|
|
130
|
+
job = mv.jobs.wait(job["id"], wait_timeout=600) # long-polls until completed / failed / cancelled
|
|
131
|
+
for row in mv.jobs.results(job["id"], registered=True): # follows every cursor page for you
|
|
132
|
+
print(row["e164"])
|
|
133
|
+
csv_text = mv.jobs.download(job["id"]) # the whole file as text (CSV, or format="ndjson")
|
|
134
|
+
mv.jobs.download_to(job["id"], "results.csv") # streams to disk; returns the bytes written
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`jobs.results_page()` returns a single page; `jobs.get(id, wait=30)` and `jobs.cancel(id)` are also available.
|
|
138
|
+
|
|
139
|
+
## Errors
|
|
140
|
+
|
|
141
|
+
Every failure raises a subclass of `MobileValidateError` with `code`, `message`, `status`, `retryable`,
|
|
142
|
+
`request_id`, `param`, `doc_url`, `suggestion` and `retry_after`:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from mobilevalidate import MobileValidate, MobileValidateError, InsufficientBalanceError, SandboxMagicOnlyError
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
mv.lookup("+447700900402", checks=["whatsapp"])
|
|
149
|
+
except InsufficientBalanceError as e:
|
|
150
|
+
print("top up:", e.message, e.request_id)
|
|
151
|
+
except SandboxMagicOnlyError as e:
|
|
152
|
+
print(e.suggestion) # how to fix it, in plain English
|
|
153
|
+
except MobileValidateError as e:
|
|
154
|
+
print(e.code, e.status, e.message, e.suggestion, e.doc_url, e.request_id)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
| Class | Code | HTTP |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `InvalidRequestError` | `invalid_request` | 400 |
|
|
160
|
+
| `TooManyNumbersError` | `too_many_numbers` | 400 |
|
|
161
|
+
| `TestNumberOnlyError` | `test_number_only` | 400 |
|
|
162
|
+
| `InvalidCursorError` | `invalid_cursor` | 400 |
|
|
163
|
+
| `UnauthorizedError` (alias `AuthenticationError`) | `unauthorized` | 401 |
|
|
164
|
+
| `InsufficientBalanceError` | `insufficient_balance` | 402 |
|
|
165
|
+
| `CostLimitExceededError` | `cost_limit_exceeded` | 402 |
|
|
166
|
+
| `InsufficientScopeError` | `insufficient_scope` | 403 |
|
|
167
|
+
| `ServiceDisabledError` | `service_disabled` | 403 |
|
|
168
|
+
| `SandboxMagicOnlyError` | `sandbox_magic_only` | 403 |
|
|
169
|
+
| `SuspectedEnumerationError` | `suspected_enumeration` | 403 |
|
|
170
|
+
| `NotFoundError` | `not_found` | 404 |
|
|
171
|
+
| `IdempotencyKeyReusedError` | `idempotency_key_reused` | 409 |
|
|
172
|
+
| `IdempotencyRequestInProgressError` | `idempotency_request_in_progress` | 409 (retried) |
|
|
173
|
+
| `TestKeyExistsError` | `test_key_exists` | 409 |
|
|
174
|
+
| `PayloadTooLargeError` | `payload_too_large` | 413 |
|
|
175
|
+
| `RateLimitedError` (alias `RateLimitError`) | `rate_limited` | 429 (retried) |
|
|
176
|
+
| `DailyCapReachedError` | `daily_cap_reached` | 429 (retried) |
|
|
177
|
+
| `SpendCapReachedError` | `spend_cap_reached` | 429 |
|
|
178
|
+
| `InternalServerError` | `internal_error` | 500 (retried) |
|
|
179
|
+
| `TemporarilyUnavailableError` | `temporarily_unavailable` | 503 (retried) |
|
|
180
|
+
| `APIError` | any other API code | — |
|
|
181
|
+
| `APIConnectionError` / `APITimeoutError` | `connection_error` / `timeout` | — (retried) |
|
|
182
|
+
| `MissingApiKeyError`, `InvalidArgumentError`, `InvalidResponseError` | SDK-side | — |
|
|
183
|
+
|
|
184
|
+
All API error classes derive from `APIError`. Per-number problems (invalid, duplicate, unknown) are not errors; they
|
|
185
|
+
appear in each row's `number_status` / `email_status` and `checks[code]["status"]`.
|
|
186
|
+
|
|
187
|
+
## Retries and timeouts
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
mv = MobileValidate(
|
|
191
|
+
timeout=30.0, # seconds per HTTP request; server long-poll time is added automatically
|
|
192
|
+
max_retries=2, # retryable errors only; 0 disables
|
|
193
|
+
wait_timeout=60.0, # overall polling budget for lookup()
|
|
194
|
+
)
|
|
195
|
+
mv.lookup("+447700900001", timeout=5, max_retries=0) # per-call overrides
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Retries use full-jitter exponential backoff (0.5 s, 1 s, … capped at 8 s) or the server's `Retry-After`. A
|
|
199
|
+
`Retry-After` above 60 s (for example a daily cap) is raised instead of waited for. The same `Idempotency-Key` is
|
|
200
|
+
sent on every attempt, so a retried POST is never charged twice. Every returned object has `.request_id` — quote it
|
|
201
|
+
when you contact support.
|
|
202
|
+
|
|
203
|
+
You can pass your own `httpx.Client` / `httpx.AsyncClient` (`http_client=`) for proxies or custom transports; the SDK
|
|
204
|
+
does not close clients it did not create. Use the client as a context manager to close its connection pool.
|
|
205
|
+
|
|
206
|
+
## Async
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
import asyncio
|
|
210
|
+
from mobilevalidate import AsyncMobileValidate
|
|
211
|
+
|
|
212
|
+
async def main():
|
|
213
|
+
async with AsyncMobileValidate(sandbox=True) as mv:
|
|
214
|
+
lookup = await mv.lookup("+447700900004", checks=["whatsapp"]) # waits ~5 s for the pending answer
|
|
215
|
+
print(lookup["status"], lookup["results"][0]["checks"]["whatsapp.registered"]["registered"])
|
|
216
|
+
async for row in mv.jobs.results("job_..."):
|
|
217
|
+
...
|
|
218
|
+
|
|
219
|
+
asyncio.run(main())
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Webhooks
|
|
223
|
+
|
|
224
|
+
Verify every webhook on the **raw** request body. `verify_webhook` checks an HMAC-SHA256 over
|
|
225
|
+
`{webhook-id}.{webhook-timestamp}.{body}` against `webhook-signature: v1,<base64>` (several signatures are allowed
|
|
226
|
+
during secret rotation), in constant time, with 5 minutes of clock tolerance. Secrets look like `whsec_<base64>`.
|
|
227
|
+
|
|
228
|
+
Flask:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
import os
|
|
232
|
+
from flask import Flask, request
|
|
233
|
+
from mobilevalidate import verify_webhook, WebhookVerificationError
|
|
234
|
+
|
|
235
|
+
app = Flask(__name__)
|
|
236
|
+
|
|
237
|
+
@app.post("/webhooks/mobilevalidate")
|
|
238
|
+
def webhook():
|
|
239
|
+
try:
|
|
240
|
+
event = verify_webhook(request.get_data(), request.headers, os.environ["MOBILEVALIDATE_WEBHOOK_SECRET"])
|
|
241
|
+
except WebhookVerificationError:
|
|
242
|
+
return "", 400
|
|
243
|
+
if event["type"] == "job.completed":
|
|
244
|
+
job_id = event["data"]["id"] # fetch rows with mv.jobs.results(job_id)
|
|
245
|
+
return "", 204
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
FastAPI:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
import os
|
|
252
|
+
from fastapi import FastAPI, Request, Response
|
|
253
|
+
from mobilevalidate import verify_webhook, WebhookVerificationError
|
|
254
|
+
|
|
255
|
+
app = FastAPI()
|
|
256
|
+
|
|
257
|
+
@app.post("/webhooks/mobilevalidate")
|
|
258
|
+
async def webhook(request: Request):
|
|
259
|
+
try:
|
|
260
|
+
event = verify_webhook(await request.body(), request.headers, os.environ["MOBILEVALIDATE_WEBHOOK_SECRET"])
|
|
261
|
+
except WebhookVerificationError:
|
|
262
|
+
return Response(status_code=400)
|
|
263
|
+
return Response(status_code=204)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Events: `lookup.completed`, `job.completed`, `job.failed`, `job.progress`, `balance.low`, `limits.cap_reached`.
|
|
267
|
+
Payloads never contain phone numbers; fetch results with your key. `sign_webhook(secret, msg_id, timestamp, body)`
|
|
268
|
+
produces a valid signature for testing your own receiver.
|
|
269
|
+
|
|
270
|
+
## Other methods
|
|
271
|
+
|
|
272
|
+
| Method | API |
|
|
273
|
+
|---|---|
|
|
274
|
+
| `lookups.get(id, wait=0)` | `GET /v1/lookups/{id}` |
|
|
275
|
+
| `services()` | `GET /v1/services` |
|
|
276
|
+
| `account.get()`, `limits.get()` | `GET /v1/account`, `GET /v1/limits` |
|
|
277
|
+
| `usage.get(from_date="2026-09-01", to_date="2026-09-30", group_by="day")` | `GET /v1/usage` |
|
|
278
|
+
| `webhook_endpoints.create(url=..., events=[...])`, `.list()`, `.delete(id)`, `.test(id)` | `/v1/webhook_endpoints` |
|
|
279
|
+
| `webhooks.verify(raw_body, headers, secret)` | same as `verify_webhook` |
|
|
280
|
+
|
|
281
|
+
## Security notes
|
|
282
|
+
|
|
283
|
+
- Keep live keys server-side; pass them via `MOBILEVALIDATE_API_KEY`, not in source code.
|
|
284
|
+
- Never log full phone numbers or e-mail addresses; mask them (for example `+44•••••••01`).
|
|
285
|
+
- Only check numbers and addresses you have a legitimate relationship with. Runs of consecutive numbers or
|
|
286
|
+
digit-variant addresses are refused (`SuspectedEnumerationError`).
|
|
287
|
+
- Use `max_cost` to cap what a single request may spend.
|
|
288
|
+
|
|
289
|
+
## Versioning
|
|
290
|
+
|
|
291
|
+
Semantic versioning. The SDK targets API `/v1`; responses may gain fields and enums may gain values within `/v1`, so
|
|
292
|
+
tolerate values you don't know (responses are plain dicts and keep unknown fields).
|
|
293
|
+
|
|
294
|
+
## Development
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
|
|
298
|
+
.venv/bin/pytest
|
|
299
|
+
.venv/bin/python -m build # sdist + wheel in dist/
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
MIT © 2026 BroadNet Technologies Inc. See [LICENSE](./LICENSE).
|