ovoid — Unofficial OVO API client for Python
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
pip install ovoid
Requires Python 3.10+ and two small dependencies: httpx
(HTTP transport) and cryptography (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).
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 samevalidate_otp()— you only change what you pass as the code.
Echo
typeback. The server rejects the call withOV00002 "type: non zero value required"when the type is empty. Same forlogin_with_pin():push_notification_idmust be non-empty (this SDK falls back todevice_idfor 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:
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_inis compared as an absolute epoch timestamp, not a duration (seeresearch/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:
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:
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:
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
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:
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 (9e2dc36) with deliberate,
researched deviations:
App-Versiondefault3.168.0andUser-Agentokhttp/4.12.0(verified against the APK; PHP uses3.166.0/okhttp/4.9.0).do_qr_payment()takes a precomputedsignature/timeinstead of PHP's experimental (APK-proven-wrong) HMAC formula. Helpers for the verified parts live inovoid.crypto.- Transport errors are retried with exponential backoff; HTTP error statuses never are.
- Methods are
snake_case, amounts acceptint | str(sent as strings, like PHP).
Full port notes: research/RESEARCH_OVOID_PHP.md. APK findings: research/RESEARCH_APK.md.
Development
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. Original PHP library by lintangtimur (MIT License). Python port maintained by AlfinAI.
License
MIT — see LICENSE.
Release files for ovoid 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| ovoid-0.1.0.tar.gz | 38.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| ovoid-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 72.0 kB
Release files / ovoid-0.1.0.tar.gz
| Download URL | ovoid-0.1.0.tar.gz |
|---|---|
| Size | 38.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b9fee75508d98595497dfa7faa3b28c1714765f9a8ff24be8e14ead947f46edf
|
|
BLAKE2b-256 checksum How to use checksums |
007c620244c1eabd7c5a943fcfc23b3ecf2391aefe2206136bff131c1fcd2e9c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.
Transparency logRelease files / ovoid-0.1.0-py3-none-any.whl
| Download URL | ovoid-0.1.0-py3-none-any.whl |
|---|---|
| Size | 33.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e581618a382be0e67cc733baab6397a47cd0521e9ed1c1a95b2c4107d3239d7c
|
|
BLAKE2b-256 checksum How to use checksums |
445a0f30faa0ea301048e679f536f76ab1e2bac28e3322ada5f3e955d3dbe99d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 10, 2026.
Transparency log