Metadata-Version: 2.4
Name: ovoid
Version: 0.1.0
Summary: Unofficial OVO (ovo.id) API client for Python — port of lintangtimur/ovoid (PHP)
Author: AlfinAI
License: MIT
Project-URL: Homepage, https://github.com/AlfinAI/ovoid-python
Project-URL: Repository, https://github.com/AlfinAI/ovoid-python
Project-URL: Issues, https://github.com/AlfinAI/ovoid-python/issues
Project-URL: Changelog, https://github.com/AlfinAI/ovoid-python/blob/main/CHANGELOG.md
Project-URL: Original, https://github.com/lintangtimur/ovoid
Keywords: ovo,ovo-api,python-sdk,ovoid,unofficial,ewallet
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
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 :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.27
Requires-Dist: cryptography>=42
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov>=6; extra == "dev"
Requires-Dist: ruff>=0.8; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Dynamic: license-file

# ovoid — Unofficial OVO API client for Python

[![Tests](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml/badge.svg)](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml)
[![PyPI](https://img.shields.io/pypi/v/ovoid.svg)](https://pypi.org/project/ovoid/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

A lightweight Python client for the OVO (`ovo.id`) mobile wallet API. It mirrors the
request/response shapes the official app uses, so endpoints behave the same way you'd see
in the app.

> Research/educational use only. Not affiliated with OVO. This library does not bypass any
> protection — it still needs real OTP/PIN credentials for the account it is used with, and
> it cannot reproduce the hardware-bound ECDSA signature used by OVO's Digibank feature.

## Installation

```bash
pip install ovoid
```

Requires Python 3.10+ and two small dependencies: [`httpx`](https://www.python-httpx.org/)
(HTTP transport) and [`cryptography`](https://cryptography.io/) (RSA PIN encryption).

## Quick start — login

OVO's login always requires a validated OTP, even for accounts that already have a PIN set.
The flow is: **request OTP → validate the code → login with PIN** (the PIN is RSA-encrypted
automatically before it leaves your machine).

```python
from ovoid import OVOID

device_id = "any-stable-string-you-generate-once"
ovo = OVOID(device_id)

# 1) Request a code: SMS with 6 digits, or a magic link carrying ?code=.
otp = ovo.auth.request_otp("+62812xxxxxxx", device_id)["otp"]
otp_ref_id, otp_type = otp["otp_ref_id"], otp["type"]  # echo `type` back below!

# 2) Validate whatever the user received (SMS code OR the link's `code` value).
validated = ovo.auth.validate_otp("+62812xxxxxxx", device_id, code, otp_ref_id, otp_type)

# 3) Log in. `otp_token` and `otp_ref_id` come from validate_otp().
login = ovo.auth.login_with_pin(
    "+62812xxxxxxx", pin, device_id,
    validated["otp"]["otp_token"], validated["otp"]["otp_ref_id"],
)

# 4) Use the access token for everything else.
ovo.client.set_access_token(login["auth"]["access_token"])
balance = ovo.balance.inquiry_balance()
print(balance["data"]["001"]["card_balance"])  # OVO Cash (legacy envelope: read ["data"]!)
```

> **Two delivery channels, one validation call.** The channel is reported via
> `otp.reff_type`: `"OTP"` = SMS code, `"LINK"` = magic link. Both are validated with the
> same `validate_otp()` — you only change what you pass as the code.

> **Echo `type` back.** The server rejects the call with `OV00002 "type: non zero value
> required"` when the type is empty. Same for `login_with_pin()`: `push_notification_id`
> must be non-empty (this SDK falls back to `device_id` for you).

`resolve_onboarding_type()` is **optional** — peek at the account's channel *before*
requesting a code (`next` is `PIN_ENTRY` | `OTP_VERIFY` | `MAGIC_LINK` | `UNKNOWN`). You do
**not** need it to log in, and its occasional `OV00013` is a red herring.

### Caching the session token

Logging in needs a fresh OTP each time, so cache the session (~24 h) to skip it:

```python
from ovoid import OVOID, token_cache

auth = token_cache.load(".ovo-token.json")
if auth is None:
    # ... OTP + login_with_pin() as above ...
    token_cache.save(".ovo-token.json", login["auth"])
    auth = login["auth"]
ovo.client.set_access_token(auth["access_token"])
```

> `expires_in` is compared as an **absolute epoch timestamp**, not a duration (see
> `research/RESEARCH_OVOID_PHP.md` §1.3e for why).

If `request_otp()` hits the cooldown (`OV00015`) on a retry, the *previous* `otp_ref_id` —
and its SMS — are usually still valid. Save it and fall back to it:

```python
from ovoid.exceptions import ApiException

try:
    otp = ovo.auth.request_otp(msisdn, device_id)["otp"]
    token_cache.save_pending_otp(".ovo-otp-pending.json", otp)
except ApiException as e:
    otp = token_cache.load_pending_otp(".ovo-otp-pending.json", ignore_expiry=True)
    if otp is None:
        raise
```

## Services

Everything is exposed on one `OVOID` instance:

| Service | Methods | Effect |
|---|---|---|
| `ovo.auth` | `request_otp()`, `validate_otp()`, `resolve_onboarding_type()`, `login_with_pin()`, `register_with_pin()`, `step_up_initiate()`, `verify_pin()`, `verify_otp()`, `resend_otp()` (+`load_public_key()`) | login / OTP / register / RBA step-up |
| `ovo.balance` | `inquiry_balance()` | read-only |
| `ovo.history` | `get_transaction_history()`, `get_tabungan_history()`, `get_pay_later_history()`, `get_transaction_detail()`, `get_recent_transactions()`, `get_receipt_content()`, `add_favorite_from_receipt()`, `delete_recent_transaction()` | read-only |
| `ovo.transfer` | `get_bank_list()`, `get_transfer_history()`, `inquiry_transfer()`, `verify_customer_is_ovo()`, `get_favorite_transfer()`, `add_favorite_bank_transfer()`, `add_favorite_p2p_transfer()`, `delete_favorite_transfer()` | read-only |
| `ovo.transfer` | `transfer_bank_direct()`, `transfer_p2p()` | **EXECUTES a transfer** |
| `ovo.payment` | `do_qr_payment()`, `get_payment_method()`, `send_payment()`, `get_tip()`, `get_cap_point()` (+merchant/deal/expiry variants) | payment / QR |
| `ovo.qris` | `qr_scan_pay()`, `generate_checkout_data()` | read-only |
| `ovo.checkout` | `do_checkout()`, `get_checkout_detail()`, `get_promos()`, `cancel_promo()` | merchant checkout |
| `ovo.billpay` | `get_categories()`, `get_billers_by_category()`, `inquiry()`, `pay_bill()`, `edit_favorite()`, … | bill payment |
| `ovo.linkage` | `get_all_linkages()`, `get_tnc()`, `accept_tnc()`, `initiate_linkage()`, `link_partner_account()`, `unlink_account()` | OAuth partner linkage |
| `ovo.kyc` | `get_customer_upgrade_status()`, `get_kyc_status()` | read-only |
| `ovo.withdrawal` | `get_withdrawal_source()`, `get_nominal_suggestions()`, `do_withdrawal()`, `generate_withdrawal_code()`, `get_withdrawal_guidance()`, … | cash out (⚠️ moves money) |
| `ovo.topup` | `get_top_up_menu()`, `get_topup_denom()`, `top_up_debit_prepare()`, `topup_debit()` | top-up, debit card (⚠️ raw card data, real money) |
| `ovo.topup_partner` | `get_store_details()`, `generate_top_up_payment_code()`, `get_top_up_payment_code()` | top-up, voucher/agent |
| `ovo.security` | `unlock()`, `unlock_action_mark()`, `unlock_and_validate_trx_id()` | wallet unlock / PIN re-validation |

Methods that move real money raise `AmountException` below OVO's 10,000 IDR minimum. Always
call the read-only `inquiry_transfer()` / `verify_customer_is_ovo()` first, and test with your
own account before relying on this in anything unattended. See `docs/services/` per service
and `examples/` for runnable flows.

### QR payments

`do_qr_payment()` sends the exact header/body shape the app uses (`app-id`, `signature`,
`time` headers + `QrPaymentRequest` body). APK reverse engineering recovered the signature
structure — `HEX(HMAC-SHA256(key, "ovo-apps" + X + millis + "POST /wallet/purchase/qr" +
base64(amountsJson)))` — but the key/`X` provisioning still needs a runtime capture
(TODO-R2). Until then, pass a precomputed `signature` + `time`:

```python
import time as _time
from ovoid import crypto

sts = crypto.qr_string_to_sign("ovo-apps", X, str(int(_time.time() * 1000)),
                               "POST /wallet/purchase/qr", b64_amounts)
sig = crypto.qr_hmac_hex(key, sts)
ovo.payment.do_qr_payment(body, signature=sig, time=... )
```

See `research/RESEARCH_APK.md` §2.3c and `research/scripts/frida/hook_crypto.js`.

## Response envelope

Most endpoints wrap responses as `{response_code, response_version, response_message, data}` —
the client unwraps this and you get `data` back directly.

A few older endpoints use `{status, data, message}` instead (e.g. `wallet/inquiry`). Those are
returned unmodified — read `["data"]` yourself (documented on the relevant method).

## Errors

Every **non-2xx** API response raises `ovoid.exceptions.ApiException`:

```python
from ovoid.exceptions import ApiException

try:
    ovo.auth.request_otp(msisdn, device_id)
except ApiException as e:
    e.response_code  # e.g. "OV00015" (cooldown), "OV00060" (invalid phone)
    str(e)           # human-readable message OVO sent
    e.payload        # full decoded response body
    e.http_status    # HTTP status code
```

Known codes (see `docs/error-codes.md`):

| Code | Meaning |
|---|---|
| `OV00002` | field validation — `"<field>: non zero value required"`. Fill the field, don't retry blindly. |
| `OV00003` / `OV00521` | rate limit / cooldown (~30 min) |
| `OV00015` | OTP cooldown (~60 s) — fall back to the pending-OTP cache |
| `OV00013` | "Anda Tidak Memiliki Akses" — generic access-denied |
| `OV00060` | invalid phone number |
| `10010001` | QR payment needs PIN re-validation → `unlock_and_validate_trx_id()` |

## Configuration

```python
from ovoid import OVOID

ovo = OVOID(
    device_id="...",
    app_version="3.168.0",   # default follows the analyzed APK; override if OVO updates
    user_agent="okhttp/4.12.0",
    timeout=30.0,            # seconds
    max_retries=2,           # transport errors only (connect/DNS/timeout) — never HTTP errors
    backoff_base=0.5,        # exponential backoff: 0.5s, 1s, 2s, ...
)
```

Need HTTP/2, a proxy, or a custom CA? Pass your own transport:

```python
import httpx
from ovoid.transport import HttpxTransport

transport = HttpxTransport(httpx.Client(http2=True, proxy="http://localhost:8080"))
ovo = OVOID(device_id="...", transport=transport)
```

## Differences from ovoid PHP

This SDK is a faithful port of
[lintangtimur/ovoid](https://github.com/lintangtimur/ovoid) (`9e2dc36`) with deliberate,
researched deviations:

- `App-Version` default `3.168.0` and `User-Agent` `okhttp/4.12.0` (verified against the APK;
  PHP uses `3.166.0` / `okhttp/4.9.0`).
- `do_qr_payment()` takes a precomputed `signature`/`time` instead of PHP's experimental
  (APK-proven-wrong) HMAC formula. Helpers for the verified parts live in `ovoid.crypto`.
- Transport errors are retried with exponential backoff; HTTP error statuses never are.
- Methods are `snake_case`, amounts accept `int | str` (sent as strings, like PHP).

Full port notes: `research/RESEARCH_OVOID_PHP.md`. APK findings: `research/RESEARCH_APK.md`.

## Development

```bash
pip install -e ".[dev]"
pytest          # 100% offline — never hits the real API
ruff check src tests && ruff format --check src tests
mypy src        # strict
python -m build
```

## Credits

This Python SDK is a port of the PHP library
[lintangtimur/ovoid](https://github.com/lintangtimur/ovoid).
Original PHP library by lintangtimur (MIT License).
Python port maintained by AlfinAI.

## License

MIT — see [LICENSE](LICENSE).
