Skip to main content

ovoid — Unofficial OVO API client for Python

Tests PyPI License: MIT

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.

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)

Source distribution for ovoid 0.1.0
File Size Uploaded
ovoid-0.1.0.tar.gz 38.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ovoid 0.1.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page