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.
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ __pycache__/
5
+ *.pyc
6
+ .pytest_cache/
7
+ *.egg-info/
@@ -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).