Metadata-Version: 2.4
Name: mxlabs-number-verify
Version: 0.1.0
Summary: Silent phone-number verification (CAMARA Number Verification) with SMS OTP fallback — thin client for the MX Labs Open Gateway.
Author: MX Labs
License: MIT
Project-URL: Homepage, https://github.com/mxlabs/number-verify-python
Project-URL: Repository, https://github.com/mxlabs/number-verify-python
Keywords: camara,open-gateway,number-verification,sim-swap,otp,phone-verification,fraud-prevention,mxlabs
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Communications :: Telephony
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# mxlabs-number-verify

Thin Python client for **silent phone-number verification** — [CAMARA Number
Verification](https://camaraproject.org/) delivered through the **MX Labs Open
Gateway** — with an automatic **SMS OTP fallback**.

Your app never touches telco keys, JWT signing, or the OIDC/CIBA dance. You call
two methods; the hosted backend brokers every operator's auth and gives you back
a yes/no.

```
┌──────────────┐  start / verify / sms_*   ┌────────────────────────┐   CAMARA   ┌────────────┐
│  your server │ ────────────────────────► │  MX Labs Open Gateway  │ ─────────► │  operator  │
│  (this SDK)  │ ◄──────────────────────── │  (keys stay here)      │            │  network   │
└──────────────┘           JSON            └────────────────────────┘            └────────────┘
```

- **Zero dependencies.** Standard library only. Python 3.8+.
- **Silent when it can be, SMS when it can't.** One flow, graceful fallback.
- **Open-core.** This client is MIT. Point it at the MX Labs hosted backend
  (recommended) or your own backend running against your own Bridge Alliance
  credentials — same wire contract.

## Install

```bash
pip install mxlabs-number-verify
```

## Quick start

```python
import os
from mxlabs_number_verify import MXNumberVerify

nv = MXNumberVerify(api_key=os.environ["MXLABS_API_KEY"])

# 1. Start a session for the number the user claims to own.
started = nv.start("+60123456789")

# 2. Have the USER'S DEVICE open started["auth_url"] over its CELLULAR data
#    connection. That mobile-network path is what silently proves the number.

# 3. Poll for the result.
result = nv.poll_verify(started["session_id"])

if result["status"] == "verified":
    grant_access(result["phone"])          # silently verified — no OTP, no friction
else:
    # 4. Fallback: send an OTP by SMS.
    sent = nv.sms_send(started["session_id"])
    code = input(f"Enter the code sent to {sent['masked']}: ")
    if nv.sms_verify(started["session_id"], code)["status"] == "verified":
        grant_access(result.get("phone"))
```

## Why "open the URL over cellular"?

Silent Number Verification works by having the **device's mobile network**
confirm the SIM's number — no SMS, no code, no user action. That only fires when
the authorize URL is fetched over the **cellular data path** (not Wi-Fi). If the
device is on Wi-Fi or the operator can't verify, the call returns
`nv_unavailable` / `not_verified` with `fallback: "sms_otp"` — switch to the SMS
flow. The SDK surfaces this for you; you never guess.

## API

### `MXNumberVerify(api_key, backend_url=..., timeout=30.0, transport=None)`

| arg           | notes                                                             |
| ------------- | ---------------------------------------------------------------- |
| `api_key`     | **required** — issued by MX Labs                                 |
| `backend_url` | hosted backend (default `https://api.mxlab.sg/sdk.php`), or your own |
| `timeout`     | per-request network timeout in seconds                           |
| `transport`   | optional `callable(url, headers, body, timeout) -> (status, dict)` for tests / a custom HTTP stack |

### Methods

- **`ping()`** → `{"status": "ok", "partner": ...}`.
- **`start(phone)`** → `{"session_id", "auth_url", "callback_url", ...}`. `phone`
  may be E.164 (`+60...`) or local digits.
- **`verify(session_id, code=None)`** → one check; `status` is `pending`,
  `verified`, `not_verified`, or `nv_unavailable`.
- **`poll_verify(session_id, interval=2.0, timeout=30.0)`** → polls `verify`
  until a terminal status or timeout. On timeout returns
  `{"status": "nv_unavailable", "reason": "timeout", "fallback": "sms_otp"}`.
- **`sms_send(session_id)`** → `{"status": "sms_sent", "masked", "expires_in"}`.
- **`sms_verify(session_id, code)`** → `{"status": "verified" | "invalid" | "expired"}`.

### Errors

Transport failures, non-2xx HTTP, and `{"status": "error"}` bodies raise
`MXNumberVerifyError` with a machine-readable `.code` (e.g. `invalid_api_key`,
`invalid_phone`, `session_not_found_or_expired`), plus `.http_status` and
`.response`. Non-error verification outcomes (`not_verified`, `nv_unavailable`,
`invalid`, `expired`) are **returned, not raised** — they're normal results.

```python
from mxlabs_number_verify import MXNumberVerifyError
try:
    nv.start("nope")
except MXNumberVerifyError as e:
    print(e.code)  # "invalid_phone"
```

## Try it in your browser

Live interactive demo (sandbox, no signup): **https://api.mxlab.sg/**

## Sandbox — try it in 10 minutes, no real phone

Ask MX Labs for a **sandbox key** (`sbx_…`). Sandbox keys are fully simulated:
they never touch a real operator or send a real SMS, and only accept these
**magic test numbers**:

| Number          | Simulates                                    |
| --------------- | -------------------------------------------- |
| `+10000000001`  | silent verification **succeeds**             |
| `+10000000002`  | silent NV unavailable → **SMS OTP fallback** |
| `+10000000003`  | NV reachable but number **doesn't match**    |

In sandbox the SMS OTP is always **`000000`**. Everything else is identical to
production — same methods, same responses (with an extra `"sandbox": true`). Swap
in a live key for real numbers; no code changes.

```python
nv = MXNumberVerify(api_key="sbx_...")
started = nv.start("+10000000001")
nv.verify(started["session_id"])   # -> {"status": "verified", "sandbox": True}
```

## API contract

The raw HTTP contract is published as an OpenAPI 3.1 spec:
[mxlabs/number-verify-openapi](https://github.com/mxlabs/number-verify-openapi).
Use it to generate a client in any other language.

## License

MIT © MX Labs
