Metadata-Version: 2.4
Name: mrxsim
Version: 1.0.9
Summary: Official Python client for MRXSIM.COM — secure SMS number purchasing & OTP retrieval (zero-knowledge API)
Author-email: MRXSIM <whomrxami@pm.me>
Maintainer-email: MRXSIM <whomrxami@pm.me>
License-Expression: MIT
Project-URL: Homepage, https://mrxsim.com
Project-URL: Documentation, https://mrxsim.com/docs
Project-URL: Repository, https://github.com/MRXSIM/MRXSIM-Universal-SMS-Automation
Project-URL: Bug Tracker, https://github.com/MRXSIM/MRXSIM-Universal-SMS-Automation/issues
Keywords: mrxsim,sms,otp,virtual-number,telegram,whatsapp,automation,api-client
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests<3,>=2.31.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == "dev"
Requires-Dist: build>=1.2.0; extra == "dev"
Requires-Dist: twine>=5.0.0; extra == "dev"
Provides-Extra: async
Requires-Dist: aiohttp<4,>=3.9.0; extra == "async"
Provides-Extra: cli
Requires-Dist: colorama<1,>=0.4.6; extra == "cli"
Dynamic: license-file

# mrxsim

Official Python client for **[MRXSIM.COM](https://mrxsim.com)** — purchase virtual numbers and retrieve SMS OTPs for every catalog service (Telegram, WhatsApp, Google, Instagram, Discord, Snapchat, Other (SMS), and more).

[![PyPI](https://img.shields.io/pypi/v/mrxsim.svg)](https://pypi.org/project/mrxsim/) · Current version: **1.0.9** · Python 3.10+ · MIT licence · [Developer Docs](https://mrxsim.com/docs)

> This repository contains the **public client library and examples only**. It talks to the documented MRXSIM HTTP API; it contains no server code, no provider integrations and no pricing or routing logic.

---

## Security first

- **No hardcoded API keys** in library source or examples. Every example reads `MRXSIM_API_KEY`.
- Prefer the environment variable `MRXSIM_API_KEY`, or a local `config.json` that is **gitignored**.
- Never commit a live key. If one is exposed, revoke it immediately (Profile → API key) and create a new one.
- Examples that spend balance (`buy_number_example.py`, `idempotent_purchase_example.py`, `direct_api_example.py`) do nothing but print a dry run unless you pass `--confirm-purchase`.

## Acceptable use

MRXSIM is built for developer testing/QA, automation and integration testing against accounts you are authorized to operate, and verification for accounts you own or manage. It is not for bypassing a third-party platform's anti-abuse controls, ban evasion, fake-account creation, KYC evasion, verifying accounts you do not own or administer, or financial-account verification. See the [Developer Docs](https://mrxsim.com/docs) for the full policy.

---

## Install

```bash
pip install mrxsim
```

From this repository (editable, with the test tools):

```bash
pip install -e ".[dev]"
```

## Configure your key

1. Open [https://mrxsim.com](https://mrxsim.com) and create an account, then top up your wallet.
2. **Profile → Get API KEY** (shown once). Keys start with `mrxs_`.
3. Provide it to your program in one of three ways:

```bash
# a) environment variable (Linux / macOS)
export MRXSIM_API_KEY="mrxs_your_key_here"
# Windows (new shell afterwards):  setx MRXSIM_API_KEY "mrxs_your_key_here"

# b) a .env file: copy the template, edit it, load it into your shell (the SDK reads real environment variables)
cp .env.example .env
set -a; source .env; set +a

# c) a gitignored config file
cp config.example.json config.json   # then edit api_key, country, service
```

`MRXSIM_API_KEY` overrides `api_key` in `config.json` when both are set. `MRXSIM_BASE_URL` overrides the API origin (default `https://mrxsim.com`).

## Quick start

```python
from mrxsim import Client

with Client() as client:                      # reads MRXSIM_API_KEY
    print(client.balance())                   # {"balance_usdt": "12.34"}

    # Discover the catalog: no purchase, no charge.
    countries = client.list_countries()
    services = client.list_services("england")
    options = client.list_options(country="england", service="telegram")

    order = client.get_number(
        country="england", service="telegram", operator="any",
        idempotency_key="my-own-stable-id-for-this-purchase",   # optional but recommended, see "Retry safety"
    )
    print(order["phone_number"], order["id"])

    sms = client.wait_for_sms(order["id"])    # polls until the OTP arrives; retries 429 / retryable 503 itself (1.0.9+)
    print(sms["sms_code"])

    # client.cancel(order["id"])              # cancel before the code arrives -> refunded
```

One-shot purchase + OTP:

```python
from mrxsim import Client

with Client(country="egypt", service="whatsapp") as client:
    result = client.buy_and_wait()
    print(result["phone_number"], result["sms_code"])
```

Choosing a specific option (instead of `operator="any"`) needs that option's `quote` from `list_options()`:

```python
row = options[0]                                   # a stable MRXSIM option code such as "opt1" -- never a provider id
order = client.get_number(country="england", service="telegram", operator=row["operator"], quote=row["quote"])
```

A quote is short-lived (about two minutes) and bound to the price you saw; a missing, expired or stale quote fails with `409` before anything is charged.

---

## Examples

Every script is in [`examples/`](examples). Run them from the repository root after `pip install -e .` (or `pip install mrxsim`) and exporting `MRXSIM_API_KEY`.

| Script | What it shows | Spends balance? | Command |
|---|---|---|---|
| `catalog_example.py` | Catalog drill-down: countries → services → options | No | `python examples/catalog_example.py england telegram` |
| `buy_number_example.py` | One purchase | **Yes** (needs `--confirm-purchase`) | `python examples/buy_number_example.py --confirm-purchase` |
| `get_otp_example.py` | Wait for the SMS of an existing order (`wait_for_sms`, which retries `429` / retryable `503` itself since 1.0.9) | No | `python examples/get_otp_example.py <order_id>` |
| `many_orders_example.py` | Wait for many orders at once with ONE shared client (threads; the client paces every poll) | No | `python examples/many_orders_example.py <order_id> <order_id> ...` |
| `status_polling_example.py` | Your own polling loop, honouring `Retry-After` (only needed when you want full manual control) | No | `python examples/status_polling_example.py <order_id>` |
| `cancel_example.py` | Cancel an order, including the cooldown and refused-cancel cases | No (refunds) | `python examples/cancel_example.py <order_id> --wait` |
| `idempotent_purchase_example.py` | Purchase with an idempotency key and the right retry rules | **Yes** (needs `--confirm-purchase`) | `python examples/idempotent_purchase_example.py --country england --service telegram --confirm-purchase` |
| `error_handling_example.py` | Every exception and how to handle it (runs fully offline) | No | `python examples/error_handling_example.py` |
| `direct_api_example.py` | The same API with plain `requests`, no SDK | **Yes** only with `--confirm-purchase` | `python examples/direct_api_example.py england telegram` |
| `rentals_pagination_example.py` | `limit` / `offset` pagination | No | `python examples/rentals_pagination_example.py` |

`mrxsim_client.py` is a small legacy command-line demo (`python mrxsim_client.py`, reads `config.json`).

---

## API surface

All paths are under `/api/v1`; authentication is the `X-API-Key` header (the catalog routes are public).

| Method | Endpoint | Client method |
|--------|----------|---------------|
| `GET` | `/api/v1/balance` | `Client.balance()` |
| `GET` | `/api/v1/sms/catalog` | `Client.list_countries()` |
| `GET` | `/api/v1/sms/services?country=…` | `Client.list_services()` |
| `GET` | `/api/v1/sms/operators?country=…&service=…` | `Client.list_options()` |
| `POST` | `/api/v1/get_number` | `Client.get_number()` / `buy_and_wait()` |
| `GET` | `/api/v1/get_sms?order_id=…` | `Client.get_sms()` / `wait_for_sms()` |
| `POST` | `/api/v1/cancel` | `Client.cancel()` |

Rental Numbers, when enabled for your account (a disabled product answers `404`, exactly like an unknown route):

| Method | Endpoint | Client method |
|--------|----------|---------------|
| `GET` | `/api/v1/rental_countries` | `client.rentals.list_countries()` |
| `GET` | `/api/v1/rental_plans?country=…` | `client.rentals.list_plans()` |
| `POST` | `/api/v1/rental_quote` | `client.rentals.quote()` |
| `POST` | `/api/v1/rental_purchase` (requires `Idempotency-Key`) | `client.rentals.purchase()` |
| `GET` | `/api/v1/rental_list` (`limit`, `offset`) | `client.rentals.list()` |
| `GET` | `/api/v1/rental_status?rental_id=…` | `client.rentals.get()` |
| `GET` | `/api/v1/rental_messages?rental_id=…` | `client.rentals.messages()` |
| `POST` | `/api/v1/rental_close` (requires `Idempotency-Key`) | `client.rentals.close()` |
| `POST` | `/api/v1/rental_renew` (requires `Idempotency-Key`; currently always refused) | `client.rentals.renew()` |

Residential Proxies, when enabled for your account:

| Method | Endpoint | Client method |
|--------|----------|---------------|
| `GET` | `/api/v1/proxy_locations` | `client.proxies.list_locations()` |
| `GET` | `/api/v1/proxy_plans` | `client.proxies.list_plans()` |
| `POST` | `/api/v1/proxy_quote` | `client.proxies.quote()` |
| `POST` | `/api/v1/proxy_purchase` (requires `Idempotency-Key`) | `client.proxies.purchase()` |
| `GET` | `/api/v1/proxy_list` | `client.proxies.list()` |
| `GET` | `/api/v1/proxy_detail?proxy_id=…` | `client.proxies.get()` |
| `GET` | `/api/v1/proxy_usage?proxy_id=…` | `client.proxies.usage()` |
| `GET` | `/api/v1/proxy_credentials?proxy_id=…` | `client.proxies.credentials()` |
| `POST` | `/api/v1/proxy_stop` (requires `Idempotency-Key`) | `client.proxies.stop()` |

Travel eSIM is **not** part of the API-key API (see "Travel eSIM" below).

### Catalog and pagination

The catalog is a drill-down — `list_countries()` → `list_services(country)` → `list_options(country=, service=)` — and each call returns its full result; it does not paginate and does not use your purchase rate budget. `list_options()` lists every option MRXSIM has genuine backing for on that country/service, **not just what is purchasable right now**: a row can carry `available: False` / `reliability: "Temporarily unavailable"` and still be a real, normally priced product. Buying such a row fails cleanly with `409` (`reason == "no_honorable_candidate"`), never a silent substitution. Prices (`price_usdt`) are decimal strings: compare them numerically (`Decimal`), because `"0.26"` and `"0.2600"` are the same amount.

Endpoints that can grow take plain `limit` / `offset` query parameters and return a bare JSON array (no cursor, no envelope): today that is `client.rentals.list(limit=100, offset=0)` (`limit` 1–500). Track the offset yourself; a page shorter than `limit` is the last one. See `examples/rentals_pagination_example.py`.

### Public order fields

Responses expose retail fields only. This is the complete `PUBLIC_ORDER_FIELDS` allowlist the client enforces (anything else is stripped client-side as defence in depth):

| Field | Meaning |
|-------|---------|
| `id` | MRXSIM order id |
| `phone_number` | Assigned number |
| `service` / `country` | Catalog codes |
| `status` | `PROCESSING` → `PENDING` → `RECEIVED` \| `EXPIRED` \| `CANCELED` |
| `price` | Retail USDT price charged |
| `sms_code` | OTP when received, else `null` |
| `substituted_operator` | The operator you asked for, if a different available one was delivered |
| `balance_usdt` | Wallet balance right after this call |
| `cancel_allowed` | `false` only inside a known cancel cooldown (not a promise that cancel will succeed) |
| `cancel_available_at` / `cancel_remaining_seconds` | When a cooldown ends, if one applies |
| `cancel_reason` / `cancel_state` | Machine-readable cooldown reason and state (`ready`, `pending_cooldown`) |
| `stars_refund_status` / `stars_refunded_amount` | Only for an order paid with Telegram Stars: its refund state after a cancel |

`PROCESSING` means the purchase is still confirming and no number exists yet; `wait_for_sms()` keeps polling through `PROCESSING` and `PENDING`. You can hold any number of orders open at once; each is tracked independently by its `id`.

---

## Retry safety

`Client` **never retries a purchase automatically** — not on a timeout, not on a network error, not on a `503`. A hidden automatic retry on a purchase call is how a client library causes a real second charge for one logical purchase. Instead it gives you what you need to retry safely when *you* choose to:

- **`idempotency_key`** on `get_number()` / `buy_and_wait()`: pass the same string across your own retry attempts (for example after catching `MrxsimTimeoutError`). MRXSIM guarantees at most one real purchase per key (scoped to your account, up to 128 characters) and returns the original order on a repeat. Omit it for an independent purchase. Never reuse one key for a different request (`409 idempotency_conflict`).
- **`retry_after`** on `MrxsimRateLimitError` (`429`) and `MrxsimServiceUnavailableError` (`503`): the server's real `Retry-After` value in seconds (`None` if absent). Prefer it over a fixed delay.

```python
import time
from mrxsim import Client, MrxsimRateLimitError, MrxsimServiceUnavailableError

KEY = "my-own-stable-id-for-this-purchase"
client = Client()
try:
    order = client.get_number(country="england", service="telegram", idempotency_key=KEY)
except (MrxsimRateLimitError, MrxsimServiceUnavailableError) as exc:
    time.sleep(exc.retry_after or 2)
    order = client.get_number(country="england", service="telegram", idempotency_key=KEY)   # the SAME key
```

Reads never mutate anything and are safe to repeat. Since **1.0.9** the polling loop `wait_for_sms()` does that for you (see "Waiting for many codes"): it retries `429` and retryable `503` answers, request timeouts and connection resets, honouring `Retry-After`. A single direct call such as `get_sms()` or `balance()` still raises the first `429`/`503` immediately, with `retry_after` set. Purchases and cancels are never retried by the client. A complete, runnable version of these rules is `examples/idempotent_purchase_example.py`.

### `MrxsimAmbiguousPurchaseError` — never retry this one with a new key

In the rare case where a charge or purchase may already have happened but MRXSIM could not yet record it, `get_number()` raises **`MrxsimAmbiguousPurchaseError`** (a subclass of `MrxsimAPIError`, so a generic `except MrxsimAPIError` also catches it — but it needs the *opposite* handling):

```python
from mrxsim import Client, MrxsimAmbiguousPurchaseError

client = Client()
try:
    order = client.get_number(country="england", service="telegram", idempotency_key=KEY)
except MrxsimAmbiguousPurchaseError as exc:
    # Do NOT retry with a new idempotency_key: that risks a genuine second purchase. Retry with the EXACT SAME key,
    # or hold and contact support with exc.purchase_intent_id.
    print(exc.purchase_intent_id)
```

`exc.purchase_intent_id` is MRXSIM's own correlation id, safe to log. For rentals the equivalent is `exc.rental_attempt_id`.

---

## Waiting for many codes (1.0.9)

`wait_for_sms()` is the one place the client retries on its own, because a poll is an idempotent read:

| Situation | Behaviour |
|---|---|
| `429` | waits, then polls again. Never shorter than the server's guidance: the `Retry-After` header, or the public `retry_after_seconds` field when the header is missing or malformed (the larger value if both are usable); never shorter than 1 s; grows with repeated failures (bounded exponential backoff, cap 30 s); up to 1 s of jitter. If the server asks for longer than the time left before `poll_timeout`, the typed error is raised at once instead of polling early. `pending_orders_limit` is not retried |
| `503` | retried only when the server marks it retryable (`retryable: true` / `code: "at_capacity"`) or sends no machine-readable verdict at all (a plain gateway 503); same waiting rules. `retryable: false` or an unknown `code` is raised |
| request timeout, dropped or refused connection (connection reset, DNS failure) | retried with backoff; TLS/certificate and proxy errors, invalid URLs and redirect loops are raised at once |
| `401` / `403`, `404`, `422`, other `4xx`/`5xx`, ambiguous-purchase errors | **never retried**: raised as the same typed exception as in 1.0.8 |
| terminal order state | `CANCELED` / `EXPIRED` raise `MrxsimOrderError` immediately; `RECEIVED` returns |
| sustained throttling | a **per-client circuit breaker**: after 30 throttling/backpressure answers within 60 s no poll is sent for 60 s (waiters sleep through the cooldown, then up to 1 s of random release jitter) |
| many orders at once | polls of all waiters of one client share one schedule, at least `60 / poll_budget_per_minute` seconds apart (default 75/min = 0.8 s), so orders started together do not poll together and one key stays inside its documented read budget; a lone waiter polls exactly as in 1.0.8 (the one difference: 1.0.8 could poll up to one interval after `poll_timeout`, 1.0.9 makes one final poll exactly at the deadline). Pacing is per client: two clients on one key each get their own budget, so use one client per key; more than about `poll_timeout / 0.8` simultaneous waiters cannot all be polled within `poll_timeout` |

Three clocks, kept apart: `poll_interval` is the pause between polls of one order; `poll_timeout` is the **total** time one `wait_for_sms()` call may take (pauses and retries included; no sleep ever runs past it, and a request already in flight can finish slightly after it); the client `timeout` is the per-request HTTP timeout (while polling it is capped by the time left, minimum 1 s). A purchase is unaffected: build the client you call `get_number()` with using `timeout=120.0`, because a purchase can take longer than the 30 s default. If the total deadline arrives while the last attempt failed transiently, that typed exception (`MrxsimRateLimitError`, `MrxsimServiceUnavailableError`, `MrxsimTimeoutError`, ...) is raised, because it is the real reason no answer arrived; otherwise `MrxsimTimeoutError`.

```python
import threading
from mrxsim import Client

client = Client()                       # ONE client per process: pacing and the circuit breaker are per client
codes: dict[str, str] = {}

def wait(order_id: str) -> None:
    codes[order_id] = client.wait_for_sms(order_id, poll_interval=3.0, poll_timeout=600.0)["sms_code"]

threads = [threading.Thread(target=wait, args=(oid,)) for oid in order_ids]   # 5, 15 or 50 orders: fine
[t.start() for t in threads]; [t.join() for t in threads]
```

With many waiters each order is polled about every `waiters × 0.8` seconds (15 orders ≈ every 12 s). An approved high-volume key can raise `poll_budget_per_minute`; pass `None` to disable the pacing. The SDK is synchronous: in an `asyncio` program call it through `asyncio.to_thread(client.wait_for_sms, order_id)` with one shared client. Pass `retry_transient=False` to the constructor for the exact 1.0.8 behaviour (the first `429`/`503`/timeout is raised). `get_sms()`, `balance()`, the catalog calls, `get_number()` and `cancel()` keep their 1.0.8 behaviour: one attempt, typed exception. A runnable version is `examples/many_orders_example.py`.

## Rentals lifecycle

```python
import uuid
from mrxsim import Client

client = Client()
countries = client.rentals.list_countries()
plans = client.rentals.list_plans("US")
plan = next(p for p in plans if p["purchasable"])           # never offer a plan with purchasable=False
print(plan["refund_disclosure"])                            # show this to your buyer before paying
quote = client.rentals.quote("US", plan["duration_type"], plan["duration_time"])
rental = client.rentals.purchase(quote["quote_id"], idempotency_key=str(uuid.uuid4()))
inbox = client.rentals.messages(rental["id"])               # a rental inbox can hold many SMS
if client.rentals.get(rental["id"])["can_close"]:
    client.rentals.close(rental["id"], idempotency_key=str(uuid.uuid4()))
```

Statuses: `ACTIVE`, `EXPIRED`, `CANCELED`, `CLOSED`. A quote is short-lived and single-use. `close()` is only valid while `can_close` is true (`409` otherwise); `renew()` is currently always refused (`409`, `reason == "renewal_not_supported"`). An unknown id and someone else's id are both `404`. `idempotency_key` is **required** on `purchase`, `close` and `renew` (omitting it raises `MrxsimConfigError`).

## Proxy lifecycle

```python
import uuid
from mrxsim import Client

client = Client()
plans = client.proxies.list_plans()
plan = next(p for p in plans if p["available"])
quote = client.proxies.quote("US", "ROTATING", plan["plan_id"])
lease = client.proxies.purchase(quote["quote_id"], idempotency_key=str(uuid.uuid4()))
creds = client.proxies.credentials(lease["id"])             # only while ACTIVE or EXHAUSTED
usage = client.proxies.usage(lease["id"])
client.proxies.stop(lease["id"], idempotency_key=str(uuid.uuid4()))   # revokes now, no auto-refund
```

Statuses: `ACTIVE`, `EXHAUSTED`, `STOP_REQUESTED`, `STOPPING`, `STOPPED`, `EXPIRED`. Credentials are never shown again once a lease is `STOPPED` or `EXPIRED` (`409`, `reason == "not_active"`). Treat `username` and `password` as secrets. `idempotency_key` is **required** on `purchase` and `stop`.

## Travel eSIM

Travel eSIM is offered through the MRXSIM website and Mini App, not through the API-key Developer API, so the SDK has no working eSIM method: every `client.esims.*` call raises `MrxsimUnsupportedError` before any network request.

```python
from mrxsim import Client, MrxsimUnsupportedError

try:
    Client().esims.list_destinations()
except MrxsimUnsupportedError:
    print("eSIM is not available through the API; use the website or Mini App.")
```

## Ambiguous states

A purchase can end in a state MRXSIM is still reconciling. Never treat it as a failure, and never retry it with a new `Idempotency-Key`:

- HTTP `202`, or `503` carrying `purchase_intent_id` (numbers) or `attempt_id` (rentals), raises `MrxsimAmbiguousPurchaseError`. Funds are reserved, not lost.
- What to do: wait, then check `get_sms()` / `rentals.list()` and your order history; retry only with the exact same `Idempotency-Key`; otherwise contact support with `exc.purchase_intent_id` or `exc.rental_attempt_id`.
- A number order in `PROCESSING` is the same idea: keep polling `get_sms()`.

## Rate limits and retry guidance

Every call shares a system-wide ceiling, and each key has separate per-action budgets (purchases, reads, and for rentals/proxies quotes and close/stop); an approved high-volume key gets larger ones. The current numbers are in the [Developer Docs](https://mrxsim.com/docs) under *Rate limits*. A `429` carries `Retry-After`, exposed as `MrxsimRateLimitError.retry_after`: wait at least that long.

Retry `429`, `500` and `503` with backoff (except the ambiguous `503` above, which must reuse the same key). Never retry `401`, `402`, `404` or `422` unchanged. `409` means read `exc.reason` / `exc.details["reason"]` first (for example fetch a fresh quote). `202` is never something to retry: it means "wait and check".

## Error semantics

| Status | Exception | Meaning |
|--------|-----------|---------|
| 401 / 403 | `MrxsimAuthError` | missing or invalid key |
| 402 | `MrxsimAPIError` (`exc.insufficient_balance`) | insufficient wallet balance |
| 404 | `MrxsimAPIError` | not found, not yours, or product not enabled |
| 409 | `MrxsimAPIError` | quote expired/invalid, action not allowed now, idempotency conflict; see `exc.reason` |
| 422 | `MrxsimAPIError` | invalid request, or a stock-out (`exc.stock_race`) |
| 429 | `MrxsimRateLimitError` | rate limited, see `retry_after` (`wait_for_sms()` retries it itself since 1.0.9) |
| 202 / 503 with an id | `MrxsimAmbiguousPurchaseError` | ambiguous, see above |
| 503 | `MrxsimServiceUnavailableError` | temporarily unavailable, retry with backoff (`wait_for_sms()` retries a retryable one itself since 1.0.9) |
| — | `MrxsimTimeoutError` | an HTTP request timed out, or `wait_for_sms()` reached `poll_timeout` |
| — | `MrxsimOrderError` | `wait_for_sms()` saw the order end as `EXPIRED` / `CANCELED` |
| — | `MrxsimConfigError` | missing/invalid configuration or argument |

All of them inherit `MrxsimError`; those that represent a server response also inherit `MrxsimAPIError` and carry `status_code` and `details`. Error bodies never contain an upstream service name: routing is internal to MRXSIM and there is no parameter to choose or influence it. `examples/error_handling_example.py` runs every case offline.

---

## Development

```bash
python -m venv .venv
# Windows: .venv\Scripts\activate
source .venv/bin/activate
pip install -e ".[dev]"
pytest
python -m build
```

The tests are fully offline (no network, no API key). See [CHANGELOG.md](CHANGELOG.md) for release notes.

## Support

- Site: [https://mrxsim.com](https://mrxsim.com)
- Docs: [https://mrxsim.com/docs](https://mrxsim.com/docs)
- Issues: [GitHub](https://github.com/MRXSIM/MRXSIM-Universal-SMS-Automation/issues)

© MRXSIM · Secure SMS Infrastructure
