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.
pip install ovoidRequires Python 3.10+ and two small dependencies: httpx
(HTTP transport) and cryptography (RSA PIN encryption).
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.
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:
raiseEverything 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 ( |
ovo.topup |
get_top_up_menu(), get_topup_denom(), top_up_debit_prepare(), topup_debit() |
top-up, debit card ( |
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.
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.
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).
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 codeKnown 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() |
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)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.
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 buildThis Python SDK is a port of the PHP library lintangtimur/ovoid. Original PHP library by lintangtimur (MIT License). Python port maintained by AlfinAI.
Questions, bug reports, or research collaboration — reach me on Telegram: @JoestarMojo.
If this project saves you time, consider sponsoring — it keeps the research going:
🇮🇩 Indonesia: Saweria
MIT — see LICENSE.