Skip to content

Repository files navigation

ovoid — Unofficial OVO API client for Python

Tests PyPI License: MIT Telegram

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 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:

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:

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-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

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.

Contact

Questions, bug reports, or research collaboration — reach me on Telegram: @JoestarMojo.

Sponsor

If this project saves you time, consider sponsoring — it keeps the research going:

Sponsor 🇮🇩 Indonesia: Saweria

License

MIT — see LICENSE.

About

Unofficial OVO (ovo.id) API client for Python — port of lintangtimur/ovoid: OTP login, balance, transactions

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages