ovoid 0.1.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.
- ovoid-0.1.0/LICENSE +26 -0
- ovoid-0.1.0/PKG-INFO +275 -0
- ovoid-0.1.0/README.md +241 -0
- ovoid-0.1.0/pyproject.toml +68 -0
- ovoid-0.1.0/setup.cfg +4 -0
- ovoid-0.1.0/src/ovoid/__init__.py +21 -0
- ovoid-0.1.0/src/ovoid/client.py +184 -0
- ovoid-0.1.0/src/ovoid/constants.py +34 -0
- ovoid-0.1.0/src/ovoid/crypto.py +121 -0
- ovoid-0.1.0/src/ovoid/exceptions.py +35 -0
- ovoid-0.1.0/src/ovoid/ovoid.py +73 -0
- ovoid-0.1.0/src/ovoid/py.typed +0 -0
- ovoid-0.1.0/src/ovoid/services/__init__.py +33 -0
- ovoid-0.1.0/src/ovoid/services/auth.py +249 -0
- ovoid-0.1.0/src/ovoid/services/balance.py +26 -0
- ovoid-0.1.0/src/ovoid/services/billpay.py +97 -0
- ovoid-0.1.0/src/ovoid/services/checkout.py +34 -0
- ovoid-0.1.0/src/ovoid/services/history.py +67 -0
- ovoid-0.1.0/src/ovoid/services/kyc.py +25 -0
- ovoid-0.1.0/src/ovoid/services/linkage.py +76 -0
- ovoid-0.1.0/src/ovoid/services/payment.py +118 -0
- ovoid-0.1.0/src/ovoid/services/qris.py +28 -0
- ovoid-0.1.0/src/ovoid/services/security.py +79 -0
- ovoid-0.1.0/src/ovoid/services/topup.py +92 -0
- ovoid-0.1.0/src/ovoid/services/topup_partner.py +37 -0
- ovoid-0.1.0/src/ovoid/services/transfer.py +160 -0
- ovoid-0.1.0/src/ovoid/services/withdrawal.py +84 -0
- ovoid-0.1.0/src/ovoid/token_cache.py +68 -0
- ovoid-0.1.0/src/ovoid/transport.py +41 -0
- ovoid-0.1.0/src/ovoid.egg-info/PKG-INFO +275 -0
- ovoid-0.1.0/src/ovoid.egg-info/SOURCES.txt +40 -0
- ovoid-0.1.0/src/ovoid.egg-info/dependency_links.txt +1 -0
- ovoid-0.1.0/src/ovoid.egg-info/requires.txt +9 -0
- ovoid-0.1.0/src/ovoid.egg-info/top_level.txt +1 -0
- ovoid-0.1.0/tests/test_auth.py +117 -0
- ovoid-0.1.0/tests/test_client.py +157 -0
- ovoid-0.1.0/tests/test_crypto.py +86 -0
- ovoid-0.1.0/tests/test_package.py +63 -0
- ovoid-0.1.0/tests/test_security.py +30 -0
- ovoid-0.1.0/tests/test_services.py +203 -0
- ovoid-0.1.0/tests/test_token_cache.py +65 -0
- ovoid-0.1.0/tests/test_transfer.py +68 -0
ovoid-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
This project is based on lintangtimur/ovoid (https://github.com/lintangtimur/ovoid)
|
|
4
|
+
Copyright (c) 2018 lintangtimur
|
|
5
|
+
Licensed under MIT License.
|
|
6
|
+
|
|
7
|
+
Portions copyright (c) 2026 AlfinAI
|
|
8
|
+
Licensed under MIT License.
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
ovoid-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ovoid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial OVO (ovo.id) API client for Python — port of lintangtimur/ovoid (PHP)
|
|
5
|
+
Author: AlfinAI
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AlfinAI/ovoid-python
|
|
8
|
+
Project-URL: Repository, https://github.com/AlfinAI/ovoid-python
|
|
9
|
+
Project-URL: Issues, https://github.com/AlfinAI/ovoid-python/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/AlfinAI/ovoid-python/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: Original, https://github.com/lintangtimur/ovoid
|
|
12
|
+
Keywords: ovo,ovo-api,python-sdk,ovoid,unofficial,ewallet
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: cryptography>=42
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-cov>=6; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
32
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
33
|
+
Dynamic: license-file
|
|
34
|
+
|
|
35
|
+
# ovoid — Unofficial OVO API client for Python
|
|
36
|
+
|
|
37
|
+
[](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml)
|
|
38
|
+
[](https://pypi.org/project/ovoid/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
A lightweight Python client for the OVO (`ovo.id`) mobile wallet API. It mirrors the
|
|
42
|
+
request/response shapes the official app uses, so endpoints behave the same way you'd see
|
|
43
|
+
in the app.
|
|
44
|
+
|
|
45
|
+
> Research/educational use only. Not affiliated with OVO. This library does not bypass any
|
|
46
|
+
> protection — it still needs real OTP/PIN credentials for the account it is used with, and
|
|
47
|
+
> it cannot reproduce the hardware-bound ECDSA signature used by OVO's Digibank feature.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install ovoid
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Requires Python 3.10+ and two small dependencies: [`httpx`](https://www.python-httpx.org/)
|
|
56
|
+
(HTTP transport) and [`cryptography`](https://cryptography.io/) (RSA PIN encryption).
|
|
57
|
+
|
|
58
|
+
## Quick start — login
|
|
59
|
+
|
|
60
|
+
OVO's login always requires a validated OTP, even for accounts that already have a PIN set.
|
|
61
|
+
The flow is: **request OTP → validate the code → login with PIN** (the PIN is RSA-encrypted
|
|
62
|
+
automatically before it leaves your machine).
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from ovoid import OVOID
|
|
66
|
+
|
|
67
|
+
device_id = "any-stable-string-you-generate-once"
|
|
68
|
+
ovo = OVOID(device_id)
|
|
69
|
+
|
|
70
|
+
# 1) Request a code: SMS with 6 digits, or a magic link carrying ?code=.
|
|
71
|
+
otp = ovo.auth.request_otp("+62812xxxxxxx", device_id)["otp"]
|
|
72
|
+
otp_ref_id, otp_type = otp["otp_ref_id"], otp["type"] # echo `type` back below!
|
|
73
|
+
|
|
74
|
+
# 2) Validate whatever the user received (SMS code OR the link's `code` value).
|
|
75
|
+
validated = ovo.auth.validate_otp("+62812xxxxxxx", device_id, code, otp_ref_id, otp_type)
|
|
76
|
+
|
|
77
|
+
# 3) Log in. `otp_token` and `otp_ref_id` come from validate_otp().
|
|
78
|
+
login = ovo.auth.login_with_pin(
|
|
79
|
+
"+62812xxxxxxx", pin, device_id,
|
|
80
|
+
validated["otp"]["otp_token"], validated["otp"]["otp_ref_id"],
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# 4) Use the access token for everything else.
|
|
84
|
+
ovo.client.set_access_token(login["auth"]["access_token"])
|
|
85
|
+
balance = ovo.balance.inquiry_balance()
|
|
86
|
+
print(balance["data"]["001"]["card_balance"]) # OVO Cash (legacy envelope: read ["data"]!)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
> **Two delivery channels, one validation call.** The channel is reported via
|
|
90
|
+
> `otp.reff_type`: `"OTP"` = SMS code, `"LINK"` = magic link. Both are validated with the
|
|
91
|
+
> same `validate_otp()` — you only change what you pass as the code.
|
|
92
|
+
|
|
93
|
+
> **Echo `type` back.** The server rejects the call with `OV00002 "type: non zero value
|
|
94
|
+
> required"` when the type is empty. Same for `login_with_pin()`: `push_notification_id`
|
|
95
|
+
> must be non-empty (this SDK falls back to `device_id` for you).
|
|
96
|
+
|
|
97
|
+
`resolve_onboarding_type()` is **optional** — peek at the account's channel *before*
|
|
98
|
+
requesting a code (`next` is `PIN_ENTRY` | `OTP_VERIFY` | `MAGIC_LINK` | `UNKNOWN`). You do
|
|
99
|
+
**not** need it to log in, and its occasional `OV00013` is a red herring.
|
|
100
|
+
|
|
101
|
+
### Caching the session token
|
|
102
|
+
|
|
103
|
+
Logging in needs a fresh OTP each time, so cache the session (~24 h) to skip it:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from ovoid import OVOID, token_cache
|
|
107
|
+
|
|
108
|
+
auth = token_cache.load(".ovo-token.json")
|
|
109
|
+
if auth is None:
|
|
110
|
+
# ... OTP + login_with_pin() as above ...
|
|
111
|
+
token_cache.save(".ovo-token.json", login["auth"])
|
|
112
|
+
auth = login["auth"]
|
|
113
|
+
ovo.client.set_access_token(auth["access_token"])
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> `expires_in` is compared as an **absolute epoch timestamp**, not a duration (see
|
|
117
|
+
> `research/RESEARCH_OVOID_PHP.md` §1.3e for why).
|
|
118
|
+
|
|
119
|
+
If `request_otp()` hits the cooldown (`OV00015`) on a retry, the *previous* `otp_ref_id` —
|
|
120
|
+
and its SMS — are usually still valid. Save it and fall back to it:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from ovoid.exceptions import ApiException
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
otp = ovo.auth.request_otp(msisdn, device_id)["otp"]
|
|
127
|
+
token_cache.save_pending_otp(".ovo-otp-pending.json", otp)
|
|
128
|
+
except ApiException as e:
|
|
129
|
+
otp = token_cache.load_pending_otp(".ovo-otp-pending.json", ignore_expiry=True)
|
|
130
|
+
if otp is None:
|
|
131
|
+
raise
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Services
|
|
135
|
+
|
|
136
|
+
Everything is exposed on one `OVOID` instance:
|
|
137
|
+
|
|
138
|
+
| Service | Methods | Effect |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `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 |
|
|
141
|
+
| `ovo.balance` | `inquiry_balance()` | read-only |
|
|
142
|
+
| `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 |
|
|
143
|
+
| `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 |
|
|
144
|
+
| `ovo.transfer` | `transfer_bank_direct()`, `transfer_p2p()` | **EXECUTES a transfer** |
|
|
145
|
+
| `ovo.payment` | `do_qr_payment()`, `get_payment_method()`, `send_payment()`, `get_tip()`, `get_cap_point()` (+merchant/deal/expiry variants) | payment / QR |
|
|
146
|
+
| `ovo.qris` | `qr_scan_pay()`, `generate_checkout_data()` | read-only |
|
|
147
|
+
| `ovo.checkout` | `do_checkout()`, `get_checkout_detail()`, `get_promos()`, `cancel_promo()` | merchant checkout |
|
|
148
|
+
| `ovo.billpay` | `get_categories()`, `get_billers_by_category()`, `inquiry()`, `pay_bill()`, `edit_favorite()`, … | bill payment |
|
|
149
|
+
| `ovo.linkage` | `get_all_linkages()`, `get_tnc()`, `accept_tnc()`, `initiate_linkage()`, `link_partner_account()`, `unlink_account()` | OAuth partner linkage |
|
|
150
|
+
| `ovo.kyc` | `get_customer_upgrade_status()`, `get_kyc_status()` | read-only |
|
|
151
|
+
| `ovo.withdrawal` | `get_withdrawal_source()`, `get_nominal_suggestions()`, `do_withdrawal()`, `generate_withdrawal_code()`, `get_withdrawal_guidance()`, … | cash out (⚠️ moves money) |
|
|
152
|
+
| `ovo.topup` | `get_top_up_menu()`, `get_topup_denom()`, `top_up_debit_prepare()`, `topup_debit()` | top-up, debit card (⚠️ raw card data, real money) |
|
|
153
|
+
| `ovo.topup_partner` | `get_store_details()`, `generate_top_up_payment_code()`, `get_top_up_payment_code()` | top-up, voucher/agent |
|
|
154
|
+
| `ovo.security` | `unlock()`, `unlock_action_mark()`, `unlock_and_validate_trx_id()` | wallet unlock / PIN re-validation |
|
|
155
|
+
|
|
156
|
+
Methods that move real money raise `AmountException` below OVO's 10,000 IDR minimum. Always
|
|
157
|
+
call the read-only `inquiry_transfer()` / `verify_customer_is_ovo()` first, and test with your
|
|
158
|
+
own account before relying on this in anything unattended. See `docs/services/` per service
|
|
159
|
+
and `examples/` for runnable flows.
|
|
160
|
+
|
|
161
|
+
### QR payments
|
|
162
|
+
|
|
163
|
+
`do_qr_payment()` sends the exact header/body shape the app uses (`app-id`, `signature`,
|
|
164
|
+
`time` headers + `QrPaymentRequest` body). APK reverse engineering recovered the signature
|
|
165
|
+
structure — `HEX(HMAC-SHA256(key, "ovo-apps" + X + millis + "POST /wallet/purchase/qr" +
|
|
166
|
+
base64(amountsJson)))` — but the key/`X` provisioning still needs a runtime capture
|
|
167
|
+
(TODO-R2). Until then, pass a precomputed `signature` + `time`:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
import time as _time
|
|
171
|
+
from ovoid import crypto
|
|
172
|
+
|
|
173
|
+
sts = crypto.qr_string_to_sign("ovo-apps", X, str(int(_time.time() * 1000)),
|
|
174
|
+
"POST /wallet/purchase/qr", b64_amounts)
|
|
175
|
+
sig = crypto.qr_hmac_hex(key, sts)
|
|
176
|
+
ovo.payment.do_qr_payment(body, signature=sig, time=... )
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
See `research/RESEARCH_APK.md` §2.3c and `research/scripts/frida/hook_crypto.js`.
|
|
180
|
+
|
|
181
|
+
## Response envelope
|
|
182
|
+
|
|
183
|
+
Most endpoints wrap responses as `{response_code, response_version, response_message, data}` —
|
|
184
|
+
the client unwraps this and you get `data` back directly.
|
|
185
|
+
|
|
186
|
+
A few older endpoints use `{status, data, message}` instead (e.g. `wallet/inquiry`). Those are
|
|
187
|
+
returned unmodified — read `["data"]` yourself (documented on the relevant method).
|
|
188
|
+
|
|
189
|
+
## Errors
|
|
190
|
+
|
|
191
|
+
Every **non-2xx** API response raises `ovoid.exceptions.ApiException`:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from ovoid.exceptions import ApiException
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
ovo.auth.request_otp(msisdn, device_id)
|
|
198
|
+
except ApiException as e:
|
|
199
|
+
e.response_code # e.g. "OV00015" (cooldown), "OV00060" (invalid phone)
|
|
200
|
+
str(e) # human-readable message OVO sent
|
|
201
|
+
e.payload # full decoded response body
|
|
202
|
+
e.http_status # HTTP status code
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Known codes (see `docs/error-codes.md`):
|
|
206
|
+
|
|
207
|
+
| Code | Meaning |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `OV00002` | field validation — `"<field>: non zero value required"`. Fill the field, don't retry blindly. |
|
|
210
|
+
| `OV00003` / `OV00521` | rate limit / cooldown (~30 min) |
|
|
211
|
+
| `OV00015` | OTP cooldown (~60 s) — fall back to the pending-OTP cache |
|
|
212
|
+
| `OV00013` | "Anda Tidak Memiliki Akses" — generic access-denied |
|
|
213
|
+
| `OV00060` | invalid phone number |
|
|
214
|
+
| `10010001` | QR payment needs PIN re-validation → `unlock_and_validate_trx_id()` |
|
|
215
|
+
|
|
216
|
+
## Configuration
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from ovoid import OVOID
|
|
220
|
+
|
|
221
|
+
ovo = OVOID(
|
|
222
|
+
device_id="...",
|
|
223
|
+
app_version="3.168.0", # default follows the analyzed APK; override if OVO updates
|
|
224
|
+
user_agent="okhttp/4.12.0",
|
|
225
|
+
timeout=30.0, # seconds
|
|
226
|
+
max_retries=2, # transport errors only (connect/DNS/timeout) — never HTTP errors
|
|
227
|
+
backoff_base=0.5, # exponential backoff: 0.5s, 1s, 2s, ...
|
|
228
|
+
)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Need HTTP/2, a proxy, or a custom CA? Pass your own transport:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
import httpx
|
|
235
|
+
from ovoid.transport import HttpxTransport
|
|
236
|
+
|
|
237
|
+
transport = HttpxTransport(httpx.Client(http2=True, proxy="http://localhost:8080"))
|
|
238
|
+
ovo = OVOID(device_id="...", transport=transport)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Differences from ovoid PHP
|
|
242
|
+
|
|
243
|
+
This SDK is a faithful port of
|
|
244
|
+
[lintangtimur/ovoid](https://github.com/lintangtimur/ovoid) (`9e2dc36`) with deliberate,
|
|
245
|
+
researched deviations:
|
|
246
|
+
|
|
247
|
+
- `App-Version` default `3.168.0` and `User-Agent` `okhttp/4.12.0` (verified against the APK;
|
|
248
|
+
PHP uses `3.166.0` / `okhttp/4.9.0`).
|
|
249
|
+
- `do_qr_payment()` takes a precomputed `signature`/`time` instead of PHP's experimental
|
|
250
|
+
(APK-proven-wrong) HMAC formula. Helpers for the verified parts live in `ovoid.crypto`.
|
|
251
|
+
- Transport errors are retried with exponential backoff; HTTP error statuses never are.
|
|
252
|
+
- Methods are `snake_case`, amounts accept `int | str` (sent as strings, like PHP).
|
|
253
|
+
|
|
254
|
+
Full port notes: `research/RESEARCH_OVOID_PHP.md`. APK findings: `research/RESEARCH_APK.md`.
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
pip install -e ".[dev]"
|
|
260
|
+
pytest # 100% offline — never hits the real API
|
|
261
|
+
ruff check src tests && ruff format --check src tests
|
|
262
|
+
mypy src # strict
|
|
263
|
+
python -m build
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Credits
|
|
267
|
+
|
|
268
|
+
This Python SDK is a port of the PHP library
|
|
269
|
+
[lintangtimur/ovoid](https://github.com/lintangtimur/ovoid).
|
|
270
|
+
Original PHP library by lintangtimur (MIT License).
|
|
271
|
+
Python port maintained by AlfinAI.
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT — see [LICENSE](LICENSE).
|
ovoid-0.1.0/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# ovoid — Unofficial OVO API client for Python
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml)
|
|
4
|
+
[](https://pypi.org/project/ovoid/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
A lightweight Python client for the OVO (`ovo.id`) mobile wallet API. It mirrors the
|
|
8
|
+
request/response shapes the official app uses, so endpoints behave the same way you'd see
|
|
9
|
+
in the app.
|
|
10
|
+
|
|
11
|
+
> Research/educational use only. Not affiliated with OVO. This library does not bypass any
|
|
12
|
+
> protection — it still needs real OTP/PIN credentials for the account it is used with, and
|
|
13
|
+
> it cannot reproduce the hardware-bound ECDSA signature used by OVO's Digibank feature.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install ovoid
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Python 3.10+ and two small dependencies: [`httpx`](https://www.python-httpx.org/)
|
|
22
|
+
(HTTP transport) and [`cryptography`](https://cryptography.io/) (RSA PIN encryption).
|
|
23
|
+
|
|
24
|
+
## Quick start — login
|
|
25
|
+
|
|
26
|
+
OVO's login always requires a validated OTP, even for accounts that already have a PIN set.
|
|
27
|
+
The flow is: **request OTP → validate the code → login with PIN** (the PIN is RSA-encrypted
|
|
28
|
+
automatically before it leaves your machine).
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from ovoid import OVOID
|
|
32
|
+
|
|
33
|
+
device_id = "any-stable-string-you-generate-once"
|
|
34
|
+
ovo = OVOID(device_id)
|
|
35
|
+
|
|
36
|
+
# 1) Request a code: SMS with 6 digits, or a magic link carrying ?code=.
|
|
37
|
+
otp = ovo.auth.request_otp("+62812xxxxxxx", device_id)["otp"]
|
|
38
|
+
otp_ref_id, otp_type = otp["otp_ref_id"], otp["type"] # echo `type` back below!
|
|
39
|
+
|
|
40
|
+
# 2) Validate whatever the user received (SMS code OR the link's `code` value).
|
|
41
|
+
validated = ovo.auth.validate_otp("+62812xxxxxxx", device_id, code, otp_ref_id, otp_type)
|
|
42
|
+
|
|
43
|
+
# 3) Log in. `otp_token` and `otp_ref_id` come from validate_otp().
|
|
44
|
+
login = ovo.auth.login_with_pin(
|
|
45
|
+
"+62812xxxxxxx", pin, device_id,
|
|
46
|
+
validated["otp"]["otp_token"], validated["otp"]["otp_ref_id"],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# 4) Use the access token for everything else.
|
|
50
|
+
ovo.client.set_access_token(login["auth"]["access_token"])
|
|
51
|
+
balance = ovo.balance.inquiry_balance()
|
|
52
|
+
print(balance["data"]["001"]["card_balance"]) # OVO Cash (legacy envelope: read ["data"]!)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> **Two delivery channels, one validation call.** The channel is reported via
|
|
56
|
+
> `otp.reff_type`: `"OTP"` = SMS code, `"LINK"` = magic link. Both are validated with the
|
|
57
|
+
> same `validate_otp()` — you only change what you pass as the code.
|
|
58
|
+
|
|
59
|
+
> **Echo `type` back.** The server rejects the call with `OV00002 "type: non zero value
|
|
60
|
+
> required"` when the type is empty. Same for `login_with_pin()`: `push_notification_id`
|
|
61
|
+
> must be non-empty (this SDK falls back to `device_id` for you).
|
|
62
|
+
|
|
63
|
+
`resolve_onboarding_type()` is **optional** — peek at the account's channel *before*
|
|
64
|
+
requesting a code (`next` is `PIN_ENTRY` | `OTP_VERIFY` | `MAGIC_LINK` | `UNKNOWN`). You do
|
|
65
|
+
**not** need it to log in, and its occasional `OV00013` is a red herring.
|
|
66
|
+
|
|
67
|
+
### Caching the session token
|
|
68
|
+
|
|
69
|
+
Logging in needs a fresh OTP each time, so cache the session (~24 h) to skip it:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from ovoid import OVOID, token_cache
|
|
73
|
+
|
|
74
|
+
auth = token_cache.load(".ovo-token.json")
|
|
75
|
+
if auth is None:
|
|
76
|
+
# ... OTP + login_with_pin() as above ...
|
|
77
|
+
token_cache.save(".ovo-token.json", login["auth"])
|
|
78
|
+
auth = login["auth"]
|
|
79
|
+
ovo.client.set_access_token(auth["access_token"])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
> `expires_in` is compared as an **absolute epoch timestamp**, not a duration (see
|
|
83
|
+
> `research/RESEARCH_OVOID_PHP.md` §1.3e for why).
|
|
84
|
+
|
|
85
|
+
If `request_otp()` hits the cooldown (`OV00015`) on a retry, the *previous* `otp_ref_id` —
|
|
86
|
+
and its SMS — are usually still valid. Save it and fall back to it:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from ovoid.exceptions import ApiException
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
otp = ovo.auth.request_otp(msisdn, device_id)["otp"]
|
|
93
|
+
token_cache.save_pending_otp(".ovo-otp-pending.json", otp)
|
|
94
|
+
except ApiException as e:
|
|
95
|
+
otp = token_cache.load_pending_otp(".ovo-otp-pending.json", ignore_expiry=True)
|
|
96
|
+
if otp is None:
|
|
97
|
+
raise
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Services
|
|
101
|
+
|
|
102
|
+
Everything is exposed on one `OVOID` instance:
|
|
103
|
+
|
|
104
|
+
| Service | Methods | Effect |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `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 |
|
|
107
|
+
| `ovo.balance` | `inquiry_balance()` | read-only |
|
|
108
|
+
| `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 |
|
|
109
|
+
| `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 |
|
|
110
|
+
| `ovo.transfer` | `transfer_bank_direct()`, `transfer_p2p()` | **EXECUTES a transfer** |
|
|
111
|
+
| `ovo.payment` | `do_qr_payment()`, `get_payment_method()`, `send_payment()`, `get_tip()`, `get_cap_point()` (+merchant/deal/expiry variants) | payment / QR |
|
|
112
|
+
| `ovo.qris` | `qr_scan_pay()`, `generate_checkout_data()` | read-only |
|
|
113
|
+
| `ovo.checkout` | `do_checkout()`, `get_checkout_detail()`, `get_promos()`, `cancel_promo()` | merchant checkout |
|
|
114
|
+
| `ovo.billpay` | `get_categories()`, `get_billers_by_category()`, `inquiry()`, `pay_bill()`, `edit_favorite()`, … | bill payment |
|
|
115
|
+
| `ovo.linkage` | `get_all_linkages()`, `get_tnc()`, `accept_tnc()`, `initiate_linkage()`, `link_partner_account()`, `unlink_account()` | OAuth partner linkage |
|
|
116
|
+
| `ovo.kyc` | `get_customer_upgrade_status()`, `get_kyc_status()` | read-only |
|
|
117
|
+
| `ovo.withdrawal` | `get_withdrawal_source()`, `get_nominal_suggestions()`, `do_withdrawal()`, `generate_withdrawal_code()`, `get_withdrawal_guidance()`, … | cash out (⚠️ moves money) |
|
|
118
|
+
| `ovo.topup` | `get_top_up_menu()`, `get_topup_denom()`, `top_up_debit_prepare()`, `topup_debit()` | top-up, debit card (⚠️ raw card data, real money) |
|
|
119
|
+
| `ovo.topup_partner` | `get_store_details()`, `generate_top_up_payment_code()`, `get_top_up_payment_code()` | top-up, voucher/agent |
|
|
120
|
+
| `ovo.security` | `unlock()`, `unlock_action_mark()`, `unlock_and_validate_trx_id()` | wallet unlock / PIN re-validation |
|
|
121
|
+
|
|
122
|
+
Methods that move real money raise `AmountException` below OVO's 10,000 IDR minimum. Always
|
|
123
|
+
call the read-only `inquiry_transfer()` / `verify_customer_is_ovo()` first, and test with your
|
|
124
|
+
own account before relying on this in anything unattended. See `docs/services/` per service
|
|
125
|
+
and `examples/` for runnable flows.
|
|
126
|
+
|
|
127
|
+
### QR payments
|
|
128
|
+
|
|
129
|
+
`do_qr_payment()` sends the exact header/body shape the app uses (`app-id`, `signature`,
|
|
130
|
+
`time` headers + `QrPaymentRequest` body). APK reverse engineering recovered the signature
|
|
131
|
+
structure — `HEX(HMAC-SHA256(key, "ovo-apps" + X + millis + "POST /wallet/purchase/qr" +
|
|
132
|
+
base64(amountsJson)))` — but the key/`X` provisioning still needs a runtime capture
|
|
133
|
+
(TODO-R2). Until then, pass a precomputed `signature` + `time`:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import time as _time
|
|
137
|
+
from ovoid import crypto
|
|
138
|
+
|
|
139
|
+
sts = crypto.qr_string_to_sign("ovo-apps", X, str(int(_time.time() * 1000)),
|
|
140
|
+
"POST /wallet/purchase/qr", b64_amounts)
|
|
141
|
+
sig = crypto.qr_hmac_hex(key, sts)
|
|
142
|
+
ovo.payment.do_qr_payment(body, signature=sig, time=... )
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
See `research/RESEARCH_APK.md` §2.3c and `research/scripts/frida/hook_crypto.js`.
|
|
146
|
+
|
|
147
|
+
## Response envelope
|
|
148
|
+
|
|
149
|
+
Most endpoints wrap responses as `{response_code, response_version, response_message, data}` —
|
|
150
|
+
the client unwraps this and you get `data` back directly.
|
|
151
|
+
|
|
152
|
+
A few older endpoints use `{status, data, message}` instead (e.g. `wallet/inquiry`). Those are
|
|
153
|
+
returned unmodified — read `["data"]` yourself (documented on the relevant method).
|
|
154
|
+
|
|
155
|
+
## Errors
|
|
156
|
+
|
|
157
|
+
Every **non-2xx** API response raises `ovoid.exceptions.ApiException`:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
from ovoid.exceptions import ApiException
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
ovo.auth.request_otp(msisdn, device_id)
|
|
164
|
+
except ApiException as e:
|
|
165
|
+
e.response_code # e.g. "OV00015" (cooldown), "OV00060" (invalid phone)
|
|
166
|
+
str(e) # human-readable message OVO sent
|
|
167
|
+
e.payload # full decoded response body
|
|
168
|
+
e.http_status # HTTP status code
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Known codes (see `docs/error-codes.md`):
|
|
172
|
+
|
|
173
|
+
| Code | Meaning |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `OV00002` | field validation — `"<field>: non zero value required"`. Fill the field, don't retry blindly. |
|
|
176
|
+
| `OV00003` / `OV00521` | rate limit / cooldown (~30 min) |
|
|
177
|
+
| `OV00015` | OTP cooldown (~60 s) — fall back to the pending-OTP cache |
|
|
178
|
+
| `OV00013` | "Anda Tidak Memiliki Akses" — generic access-denied |
|
|
179
|
+
| `OV00060` | invalid phone number |
|
|
180
|
+
| `10010001` | QR payment needs PIN re-validation → `unlock_and_validate_trx_id()` |
|
|
181
|
+
|
|
182
|
+
## Configuration
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from ovoid import OVOID
|
|
186
|
+
|
|
187
|
+
ovo = OVOID(
|
|
188
|
+
device_id="...",
|
|
189
|
+
app_version="3.168.0", # default follows the analyzed APK; override if OVO updates
|
|
190
|
+
user_agent="okhttp/4.12.0",
|
|
191
|
+
timeout=30.0, # seconds
|
|
192
|
+
max_retries=2, # transport errors only (connect/DNS/timeout) — never HTTP errors
|
|
193
|
+
backoff_base=0.5, # exponential backoff: 0.5s, 1s, 2s, ...
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Need HTTP/2, a proxy, or a custom CA? Pass your own transport:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
import httpx
|
|
201
|
+
from ovoid.transport import HttpxTransport
|
|
202
|
+
|
|
203
|
+
transport = HttpxTransport(httpx.Client(http2=True, proxy="http://localhost:8080"))
|
|
204
|
+
ovo = OVOID(device_id="...", transport=transport)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Differences from ovoid PHP
|
|
208
|
+
|
|
209
|
+
This SDK is a faithful port of
|
|
210
|
+
[lintangtimur/ovoid](https://github.com/lintangtimur/ovoid) (`9e2dc36`) with deliberate,
|
|
211
|
+
researched deviations:
|
|
212
|
+
|
|
213
|
+
- `App-Version` default `3.168.0` and `User-Agent` `okhttp/4.12.0` (verified against the APK;
|
|
214
|
+
PHP uses `3.166.0` / `okhttp/4.9.0`).
|
|
215
|
+
- `do_qr_payment()` takes a precomputed `signature`/`time` instead of PHP's experimental
|
|
216
|
+
(APK-proven-wrong) HMAC formula. Helpers for the verified parts live in `ovoid.crypto`.
|
|
217
|
+
- Transport errors are retried with exponential backoff; HTTP error statuses never are.
|
|
218
|
+
- Methods are `snake_case`, amounts accept `int | str` (sent as strings, like PHP).
|
|
219
|
+
|
|
220
|
+
Full port notes: `research/RESEARCH_OVOID_PHP.md`. APK findings: `research/RESEARCH_APK.md`.
|
|
221
|
+
|
|
222
|
+
## Development
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
pip install -e ".[dev]"
|
|
226
|
+
pytest # 100% offline — never hits the real API
|
|
227
|
+
ruff check src tests && ruff format --check src tests
|
|
228
|
+
mypy src # strict
|
|
229
|
+
python -m build
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Credits
|
|
233
|
+
|
|
234
|
+
This Python SDK is a port of the PHP library
|
|
235
|
+
[lintangtimur/ovoid](https://github.com/lintangtimur/ovoid).
|
|
236
|
+
Original PHP library by lintangtimur (MIT License).
|
|
237
|
+
Python port maintained by AlfinAI.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ovoid"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial OVO (ovo.id) API client for Python — port of lintangtimur/ovoid (PHP)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "AlfinAI" }]
|
|
13
|
+
keywords = ["ovo", "ovo-api", "python-sdk", "ovoid", "unofficial", "ewallet"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"cryptography>=42",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/AlfinAI/ovoid-python"
|
|
32
|
+
Repository = "https://github.com/AlfinAI/ovoid-python"
|
|
33
|
+
Issues = "https://github.com/AlfinAI/ovoid-python/issues"
|
|
34
|
+
Changelog = "https://github.com/AlfinAI/ovoid-python/blob/main/CHANGELOG.md"
|
|
35
|
+
Original = "https://github.com/lintangtimur/ovoid"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8",
|
|
40
|
+
"pytest-cov>=6",
|
|
41
|
+
"ruff>=0.8",
|
|
42
|
+
"mypy>=1.11",
|
|
43
|
+
"build>=1.2",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
51
|
+
pythonpath = ["src"]
|
|
52
|
+
addopts = "--cov=ovoid --cov-report=term-missing --cov-fail-under=80 -q"
|
|
53
|
+
|
|
54
|
+
[tool.coverage.run]
|
|
55
|
+
source = ["ovoid"]
|
|
56
|
+
omit = ["*/tests/*"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
target-version = "py310"
|
|
60
|
+
line-length = 110
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
64
|
+
|
|
65
|
+
[tool.mypy]
|
|
66
|
+
python_version = "3.10"
|
|
67
|
+
strict = true
|
|
68
|
+
show_error_codes = true
|
ovoid-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""ovoid — Unofficial OVO (ovo.id) API client for Python.
|
|
2
|
+
|
|
3
|
+
Python port of the PHP library ``lintangtimur/ovoid``
|
|
4
|
+
(https://github.com/lintangtimur/ovoid, MIT). See README.md (Credits) and
|
|
5
|
+
``research/RESEARCH_OVOID_PHP.md`` for the port reference.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import OVOClient
|
|
9
|
+
from .exceptions import AmountException, ApiException, OvoidException
|
|
10
|
+
from .ovoid import OVOID
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"OVOID",
|
|
16
|
+
"OVOClient",
|
|
17
|
+
"OvoidException",
|
|
18
|
+
"ApiException",
|
|
19
|
+
"AmountException",
|
|
20
|
+
"__version__",
|
|
21
|
+
]
|