Metadata-Version: 2.4
Name: mrxsim
Version: 1.0.8
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 app/social service (Telegram, WhatsApp, Google, Instagram, Discord, Snapchat, Other (SMS), and more).

> Brand: **MRXSIM** · Domain: **https://mrxsim.com** · Package: **`mrxsim`**

---

## Security first

- **No hardcoded API keys** in library source or examples.
- Prefer environment variable ``MRXSIM_API_KEY``.
- Or load a local ``config.json`` that is **gitignored**.
- Never commit live keys. Revoke immediately if exposed.

---

## Install

```bash
pip install mrxsim
```

From this repository (editable):

```bash
pip install -e .
```

---

## Quick start (environment variable)

```bash
export MRXSIM_API_KEY="mrxs_your_key_here"   # Linux / macOS
# setx MRXSIM_API_KEY "mrxs_your_key_here"  # Windows (new shell)
```

```python
from mrxsim import Client

with Client() as client:
    # Discover the catalog — no country/service needed to browse.
    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=options[0]["operator"],  # a stable MRXSIM option code — never a provider ID
        idempotency_key="my-own-stable-id-for-this-purchase",  # optional, see "Retry safety" below
    )
    print(order["phone_number"], order["id"])

    sms = client.wait_for_sms(order["id"])
    print(sms["sms_code"])

    # client.cancel(order["id"])  # if you need to cancel before the OTP arrives
```

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"])
```

---

## Quick start (config file)

```bash
cp config.example.json config.json
# edit config.json → set api_key, country, service
```

```python
from mrxsim import Client

client = Client.from_config("config.json")
order = client.get_number()
sms = client.wait_for_sms(order["id"])
client.close()
```

``MRXSIM_API_KEY`` overrides ``api_key`` in the file when set.

---

## Get your API key

1. Open [https://mrxsim.com](https://mrxsim.com) and create an account.
2. Top up with **Crypto** (USDT / supported networks).
3. **Profile → Get API KEY** (shown once at create/regenerate).
4. Export ``MRXSIM_API_KEY`` or paste into gitignored ``config.json``.

---

## API surface

| 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()` |
| `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` | `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 Developer API: it is offered through the
signed-in website / Mini App only, so `client.esims.*` raises
`MrxsimUnsupportedError` before any network call. See "Travel eSIM" below.

Catalog discovery (`list_countries`/`list_services`/`list_options`) is public MRXSIM
data — no purchase, no debit — and does not consume your purchase-tier rate budget.
An `operator` value from `list_options()` is a stable, MRXSIM-owned option code
(e.g. `"opt1"`, `"opt2"` for a service with more than one genuinely distinct price
tier) — never an upstream provider identifier — pass it straight to `get_number()`.

`list_options()` lists every option MRXSIM has ever had genuine backing for on that
country/service, **not just what's purchasable right now** — a row can carry
`available: False` / `reliability: "Temporarily unavailable"` and still be a real,
normally priced product; don't assume every row is instantly purchasable, and don't
drop one from your own UI/cache just because it's temporarily unavailable (MRXSIM
doesn't either — it resumes automatically once real backing returns). Buying such a
row fails cleanly with a 409 (`reason == "no_honorable_candidate"`), never a silent
substitution or a 500. Compare `price_usdt` numerically, not as a string — the same
price can render with a different number of decimal places between calls (e.g.
`"0.26"` vs. `"0.2600"`).

Header on every call:

```http
X-API-Key: YOUR_KEY
```

### Public order fields (zero-knowledge)

Successful responses expose **retail** fields only — aligned with the MRXSIM
server `OrderPublicOut` contract. This is the complete `PUBLIC_ORDER_FIELDS`
allowlist the client enforces client-side (see "Retry safety" and the
changelog below for the newer fields in context):

| Field | Meaning |
|-------|---------|
| `id` | MRXSIM order UUID |
| `phone_number` | Assigned number |
| `service` / `country` | Catalog codes |
| `status` | Order status |
| `price` | Retail USDT price charged |
| `sms_code` | OTP when received |
| `substituted_operator` | Set when Smart Auto-Fallback delivered a different (never worse) operator than requested |
| `balance_usdt` | Wallet balance remaining after this purchase |
| `cancel_allowed` | Whether this order is currently cancelable at all |
| `cancel_available_at` | ISO timestamp cancellation becomes available, if in cooldown |
| `cancel_remaining_seconds` | Server-computed seconds left in a cancel cooldown, if any |
| `stars_refund_status` / `stars_refunded_amount` | Only for an order funded through Telegram Stars: the state and amount of its refund after a cancel; otherwise `null` |

Order `status` values: `PROCESSING` (purchase confirming, no number yet) then
`PENDING` (number issued, waiting for the code) then `RECEIVED` | `EXPIRED` |
`CANCELED`. `wait_for_sms()` keeps polling through `PROCESSING` and `PENDING`.

Internal operational metrics and upstream routing identifiers are **not** part
of the public API. The client also sanitizes responses client-side if any such
fields ever appear (defense in depth).

Docs: [https://mrxsim.com/docs](https://mrxsim.com/docs)

## Retry safety

`Client` **never automatically retries `get_number()`** — not on a timeout, not
on a network error, not on a `503`. A hidden automatic retry on a purchase call
is exactly how a client library accidentally causes a real second charge for
one logical purchase, so this client simply doesn't do it. What it gives you
instead, so *you* can retry safely when you choose to:

- **`idempotency_key`** on `get_number()` — pass the same string across your
  own retry attempts (e.g. after catching `MrxsimTimeoutError`) and MRXSIM
  guarantees at most one real purchase for that key, returning the original
  order on a repeat instead of buying a second number. Omit it for a normal,
  independent purchase.
- **`MrxsimRateLimitError.retry_after`** (`429`) and
  **`MrxsimServiceUnavailableError.retry_after`** (`503`, MRXSIM's own
  capacity backpressure — distinct from a per-key rate limit) — both carry the
  server's real `Retry-After` value in seconds (`None` if the response didn't
  include one). Prefer it over a fixed client-side delay:

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

client = Client()
try:
    order = client.get_number(idempotency_key="my-own-stable-id-for-this-purchase")
except (MrxsimRateLimitError, MrxsimServiceUnavailableError) as exc:
    if exc.retry_after:
        time.sleep(exc.retry_after)
        order = client.get_number(idempotency_key="my-own-stable-id-for-this-purchase")
    else:
        raise
```

Plain read calls (`get_sms()`, status polling inside `wait_for_sms()`) are
naturally safe to retry on your own — they never mutate anything — so this
client doesn't add speculative auto-retry logic there either; keep your own
retry loop as simple or as sophisticated as your application needs.

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

For the rare case where a real charge/vendor purchase may already have
happened but MRXSIM's own record of it failed to save, `get_number()` raises
**`MrxsimAmbiguousPurchaseError`** (a subclass of `MrxsimAPIError`, so a
generic `except MrxsimAPIError` still catches it — but it needs the
*opposite* handling from every other error above):

```python
from mrxsim import Client, MrxsimAmbiguousPurchaseError

client = Client()
try:
    order = client.get_number(idempotency_key="my-own-stable-id-for-this-purchase")
except MrxsimAmbiguousPurchaseError as exc:
    # Do NOT retry with a new idempotency_key — that risks a genuine second
    # real purchase. Retry with the EXACT SAME key, or hold and contact
    # support with exc.purchase_intent_id.
    order = client.get_number(idempotency_key="my-own-stable-id-for-this-purchase")
```

`exc.purchase_intent_id` is MRXSIM's own internal correlation id (never
provider-derived, safe to log) — keep it if you need to contact support
instead of retrying immediately.

---

## 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`.

## 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.

## Travel eSIM

Travel eSIM is offered through the MRXSIM website and Mini App, not through the
API-key Developer API, so there is no eSIM method in this SDK. Purchase can be
temporarily unavailable; on the signed-in eSIM surface that is signalled by a
`404` (product switched off, identical to an unknown route), a `503` with
`reason` `provider_unavailable`, or a `503` daily-capacity reason. A `202` with
`reason == "ambiguous_purchase_pending_reconciliation"` means the outcome is
still being confirmed: your wallet stays debited until it is resolved.

## 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()` / 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; each key also has per-action budgets
(purchase, reads, and for rentals/proxies quote and close/stop), and an approved
high-volume key gets a larger multiple. A `429` carries `Retry-After`, exposed as
`MrxsimRateLimitError.retry_after`: wait at least that long. Retry `429`, `500`
and `503` with backoff; never retry `401`, `402`, `404` or `422` unchanged. `409`
means read `exc.details["reason"]` first (for example fetch a fresh quote).
This client never auto-retries a purchase.

## Error semantics

| Status | Exception | Meaning |
|--------|-----------|---------|
| 401 | `MrxsimAuthError` | missing or invalid key |
| 402 | `MrxsimAPIError` | 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 `details["reason"]` |
| 422 | `MrxsimAPIError` | invalid request, or a stock-out (`details["stock_race"]`) |
| 429 | `MrxsimRateLimitError` | rate limited, see `retry_after` |
| 202 / 503 with an id | `MrxsimAmbiguousPurchaseError` | ambiguous, see above |
| 503 | `MrxsimServiceUnavailableError` | temporarily unavailable, retry with backoff |

Error bodies never contain an upstream service name. Routing is internal to
MRXSIM: there is no parameter to choose or influence the upstream source.

---

## Changelog

**Note on Rental Numbers / Travel eSIM / Residential Proxies (added
2026-09-29):** the dated entries below track this SDK's ORIGINAL
single-product (Temporary Numbers) iteration history. `client.rentals`,
`client.esims`, and `client.proxies` were each added later as fully
separate product domains — see their own sections above ("Rental
Numbers", "Travel eSIM", "Proxy lifecycle") for what each one does and
how to use it. None of the three ever got its own dated changelog entry
when it landed, and this SDK has never been published to PyPI in any
state that lacked any of them — there is no real prior published version
a consumer could have been missing them from — so backfilling precise
per-domain "added in version X" entries here would just be fabricating
history this package never actually had. What matters for a real
consumer is simple: version 1.0.8 (the current, not-yet-published
candidate) includes all four product domains (SMS, Rentals, eSIM,
Proxy) as documented above, in full.

*Docs-only clarification (2026-09-04, MRXSIM Major Rework Program, Agent
20, "Docs Final Update" pass)* — no code or version change. This pass
runs after the owner-directed Telegram/Website "always clickable" UX
corrections and the final red-team revalidation, and re-verified this
package's docstrings against the actual current server code rather than
re-stating the prior pass's wording. Those two UX corrections were a
Telegram-bot/Website presentation-layer fix only — they never touched
this Developer API's wire contract, which had no client-side gate to
begin with. One small wording fix made: `get_number()`'s docstring
described a stale quote as "no longer honorable" (an internal-jargon
phrase); reworded to plain English ("no longer valid"). The literal,
correct `reason` value the server actually returns
(`no_honorable_candidate`) is unchanged and still documented verbatim.

*Docs-only clarification (2026-09-04, MRXSIM Major Rework Program, Agent
20)* — no code or version change. `list_options()`'s docstring and the
catalog-discovery note above were corrected/clarified: the previous
`reliability` example values (`"High"`, `"Unrated"`) did not match the
labels the server actually sends (`"Available"` / `"Limited availability"`
/ `"Temporarily unavailable"`), and neither this README nor the docstring
explained that an `available: False` row is a real, permanently listed
product rather than one about to vanish — the "always-visible catalog"
contract. Also added: a note to compare `price_usdt` numerically, never as
a string (the same price can render with a different decimal-place count
between calls). See `docs/rework_agent20_docs_sdk_findings.md` in the main
MRXSIM repository for the full reasoning; nothing here changes this
package's behavior, so the version stays 1.0.6.

### 1.0.8

- **`cancel_reason`/`cancel_state` now correctly returned by `get_sms()` and
  `cancel()`, not just `get_number()`** — real bug found and fixed
  server-side (2026-09-28): the cancellation-policy fields added in 1.0.6
  were only ever wired into `POST /get_number`'s response; `GET /get_sms`
  and `POST /cancel` kept returning `null`/`null` for both fields
  regardless of the order's real state, so a caller polling `get_sms()`
  got a different (wrong) answer than calling `get_number()` on the same
  order — a real parity break against this file's own "a developer must
  never see a different answer than the retail UI would" principle. Both
  fields were already in this client's own `PUBLIC_ORDER_FIELDS`
  allowlist (added in 1.0.6) and needed no client-side change once the
  server started sending them consistently — this version bump exists
  because that server-side fix genuinely changed what real API responses
  contain, and the version number had never been updated to reflect it.
- **Trimmed `wait_for_sms()`'s internal terminal-failure status set** to
  match the real, documented public status vocabulary exactly
  (`PROCESSING`/`PENDING`/`RECEIVED`/`EXPIRED`/`CANCELED`) — real gap
  found (Phase 9 API-freeze audit): the set also listed `"CANCELLED"`
  (double-L), `"TIMEOUT"`, and `"FAILED"`, none of which the server has
  ever actually assigned to a real order. Those were unreachable dead
  branches, not a wider real vocabulary — no behavior change for any real
  order, since those statuses never occurred.
- **`MrxsimUnsupportedError` now exported from the package root**
  (`mrxsim.MrxsimUnsupportedError`) — real gap found (external-developer
  validation pass): the public docs already name this as what every
  `client.esims.*` method raises, but it was only reachable via the
  undocumented internal path `from mrxsim.esims import
  MrxsimUnsupportedError`. `client.esims`/`EsimsAPI` itself remains
  intentionally unexported (there is no live eSIM endpoint to use it
  against).
- **`MrxsimAuthError` is now a real `MrxsimAPIError` subclass, with a
  real `status_code`** — real gap found (external-developer validation
  pass): every other exception representing an actual HTTP response
  (`MrxsimRateLimitError`, `MrxsimServiceUnavailableError`,
  `MrxsimAmbiguousPurchaseError`) already inherited from `MrxsimAPIError`
  and carried `status_code`; `MrxsimAuthError` did not, so a reasonable
  `except MrxsimAPIError as e: handle(e.status_code)` catch-all for any
  HTTP error silently never caught a 401/403. `status_code` is `401` or
  `403` for a real server response, `None` for this client's own
  pre-flight placeholder-key check (no HTTP request made). No import
  path changed — `from mrxsim import MrxsimAuthError` still works
  exactly as before.
- **Omitting `idempotency_key` entirely (not just passing `""`) on
  `rentals.purchase`/`close`/`renew`/`proxies.purchase`/`stop` now
  raises `MrxsimConfigError`, not a bare Python `TypeError`** — real gap
  found (external-developer validation pass): these five methods
  required `idempotency_key` as a keyword-only argument with no default,
  so Python's own call machinery raised `TypeError` before this SDK's
  code — and its documented `MrxsimConfigError` — ever ran, breaking the
  "always catch a documented `MrxsimXxxError`" idiom this package
  otherwise upholds everywhere else. Now typed `str | None = None`;
  omitting it or passing `""` both raise the same `MrxsimConfigError`.
- Two documentation corrections on the live Developer Docs page (not an
  SDK code change, no version-relevant behavior change): `list_options()`'s
  `name` field is a permanent per-tier label assigned once and never
  recomputed, not "assigned fresh from current rank" as the page
  previously (and incorrectly) said — the live behavior was already
  correct, only the description was wrong; and the Rental Numbers
  `rental_quote` example now explicitly notes it currently returns `409
  insufficient_stock` given today's genuinely empty inventory, rather
  than looking like a copy-paste mistake.
- User-Agent bumped to `mrxsim-python/1.0.8`.

### 1.0.7

- **`MrxsimAmbiguousPurchaseError`** — real gap found and closed: this
  exception class has existed in `mrxsim.exceptions` since the ambiguous
  -purchase safety work landed, but was never re-exported from the
  top-level `mrxsim` package or listed in `__all__` — a caller following
  this README's own documented pattern (`from mrxsim import
  MrxsimAmbiguousPurchaseError`) would get an `ImportError`. Now
  correctly exported; see "MrxsimAmbiguousPurchaseError — never retry
  this one with a new key" below for the full retry-safety guidance this
  exception exists for. No behavior change — `_raise_for_status()` was
  already raising this exception type correctly; only its discoverability
  was broken.
- User-Agent bumped to `mrxsim-python/1.0.7`.

### 1.0.6

- **`Client.balance()`** — real gap found and closed: the server's
  `GET /api/v1/balance` endpoint has existed since 2026-09-01, but this
  client never exposed a dedicated method for it. Returns
  `{"balance_usdt": "..."}`; cheap, idempotent, does not consume your
  purchase-tier rate budget.
- **`PUBLIC_ORDER_FIELDS` widened to match the real server `OrderPublicOut`
  exactly** — real bug found and fixed: `balance_usdt`, `cancel_allowed`,
  `cancel_available_at`, and `cancel_remaining_seconds` (all real fields
  the server has sent on every `get_number()`/`get_sms()`/`cancel()`
  response since 2026-09-01) were being silently stripped by this
  client's own sanitizer before this fix — contradicting `get_sms()`'s
  and `cancel()`'s own documented return values. Every prior version back
  to 1.0.3 was affected. If you're on an older version, upgrade — no
  code changes needed on your end, you'll simply start receiving fields
  the server was already sending.
- User-Agent bumped to `mrxsim-python/1.0.6`.

### 1.0.3

- **`get_number(idempotency_key=...)`** — optional, forwarded as an
  `Idempotency-Key` header; a retry with the same key (and the same
  country/service/operator) returns the original order instead of a second
  purchase. Omitted by default — this client never generates or reuses a key
  on your behalf.
- **`MrxsimRateLimitError`/new `MrxsimServiceUnavailableError`** now expose a
  real `retry_after` (seconds) parsed from the server's `Retry-After` header,
  `None` if absent. `MrxsimServiceUnavailableError` is new — raised on `503`
  (MRXSIM's own capacity backpressure, distinct from the per-key `429`).
- New "Retry safety" section above — documents that this client never
  automatically retries a purchase call, by design.
- **`list_countries()`/`list_services()`/`list_options()`** — real catalog
  discovery, added 2026-08-31 (this version was never published while this
  gap existed, so it's folded into 1.0.3 rather than bumping to 1.0.4). Calls
  MRXSIM's existing public catalog endpoints directly — same canonical data
  Website/Mini App/Bot render, zero provider identity ever included.
- **`cancel(order_id)`** — added 2026-08-31, alongside a real server-side
  developer-API cancel endpoint (there was none before). Reuses the same
  proven cancel/refund logic the web/bot surface already uses — idempotent,
  provider-neutral errors, refunds only on a confirmed provider outcome.
- User-Agent bumped to `mrxsim-python/1.0.3`.

### 1.0.2

- Sanitize client responses to strip internal operational metrics and upstream routing identifiers.
- Harden public documentation for white-label / zero-knowledge API alignment.
- User-Agent bumped to `mrxsim-python/1.0.2`.

### 1.0.1

- Document and enforce zero-knowledge public order fields.
- User-Agent bumped to `mrxsim-python/1.0.1`.

---

## Examples

```bash
export MRXSIM_API_KEY="…"
python examples/buy_number_example.py
python examples/get_otp_example.py <order_id>
```

---

## Development

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

---

## 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
