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.
Files changed (42) hide show
  1. ovoid-0.1.0/LICENSE +26 -0
  2. ovoid-0.1.0/PKG-INFO +275 -0
  3. ovoid-0.1.0/README.md +241 -0
  4. ovoid-0.1.0/pyproject.toml +68 -0
  5. ovoid-0.1.0/setup.cfg +4 -0
  6. ovoid-0.1.0/src/ovoid/__init__.py +21 -0
  7. ovoid-0.1.0/src/ovoid/client.py +184 -0
  8. ovoid-0.1.0/src/ovoid/constants.py +34 -0
  9. ovoid-0.1.0/src/ovoid/crypto.py +121 -0
  10. ovoid-0.1.0/src/ovoid/exceptions.py +35 -0
  11. ovoid-0.1.0/src/ovoid/ovoid.py +73 -0
  12. ovoid-0.1.0/src/ovoid/py.typed +0 -0
  13. ovoid-0.1.0/src/ovoid/services/__init__.py +33 -0
  14. ovoid-0.1.0/src/ovoid/services/auth.py +249 -0
  15. ovoid-0.1.0/src/ovoid/services/balance.py +26 -0
  16. ovoid-0.1.0/src/ovoid/services/billpay.py +97 -0
  17. ovoid-0.1.0/src/ovoid/services/checkout.py +34 -0
  18. ovoid-0.1.0/src/ovoid/services/history.py +67 -0
  19. ovoid-0.1.0/src/ovoid/services/kyc.py +25 -0
  20. ovoid-0.1.0/src/ovoid/services/linkage.py +76 -0
  21. ovoid-0.1.0/src/ovoid/services/payment.py +118 -0
  22. ovoid-0.1.0/src/ovoid/services/qris.py +28 -0
  23. ovoid-0.1.0/src/ovoid/services/security.py +79 -0
  24. ovoid-0.1.0/src/ovoid/services/topup.py +92 -0
  25. ovoid-0.1.0/src/ovoid/services/topup_partner.py +37 -0
  26. ovoid-0.1.0/src/ovoid/services/transfer.py +160 -0
  27. ovoid-0.1.0/src/ovoid/services/withdrawal.py +84 -0
  28. ovoid-0.1.0/src/ovoid/token_cache.py +68 -0
  29. ovoid-0.1.0/src/ovoid/transport.py +41 -0
  30. ovoid-0.1.0/src/ovoid.egg-info/PKG-INFO +275 -0
  31. ovoid-0.1.0/src/ovoid.egg-info/SOURCES.txt +40 -0
  32. ovoid-0.1.0/src/ovoid.egg-info/dependency_links.txt +1 -0
  33. ovoid-0.1.0/src/ovoid.egg-info/requires.txt +9 -0
  34. ovoid-0.1.0/src/ovoid.egg-info/top_level.txt +1 -0
  35. ovoid-0.1.0/tests/test_auth.py +117 -0
  36. ovoid-0.1.0/tests/test_client.py +157 -0
  37. ovoid-0.1.0/tests/test_crypto.py +86 -0
  38. ovoid-0.1.0/tests/test_package.py +63 -0
  39. ovoid-0.1.0/tests/test_security.py +30 -0
  40. ovoid-0.1.0/tests/test_services.py +203 -0
  41. ovoid-0.1.0/tests/test_token_cache.py +65 -0
  42. 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
+ [![Tests](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml/badge.svg)](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml)
38
+ [![PyPI](https://img.shields.io/pypi/v/ovoid.svg)](https://pypi.org/project/ovoid/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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
+ [![Tests](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml/badge.svg)](https://github.com/AlfinAI/ovoid-python/actions/workflows/test.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/ovoid.svg)](https://pypi.org/project/ovoid/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ]