lintangtimur / ovoid
Unofficial OVO (ovo.id) API client
Requires
- php: ^8.1
- ext-curl: *
- ext-json: *
- ext-openssl: *
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
ovoid — Unofficial OVO API client for PHP
A lightweight, zero-dependency PHP client for the OVO (ovo.id) mobile wallet API. It mirrors
the request/response shapes the official app uses, so the 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.
Requirements
- PHP 8.1+
ext-curl,ext-openssl- No other dependencies
composer require lintangtimur/ovoid
PORTS
Jika PHP tidak nyaman bagimu bisa mencoba bahasa pemrograman lain, atau jika ingin menulis dibahasa tersebut silahkan ajukan Issue baru nanti akan saya list disini
Python
Flutter
GO
Java
Ruby
Javascript / nodejs
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 (PIN is RSA-encrypted automatically before it leaves your machine).
use Stelin\OVOID; $deviceId = 'any-stable-string-you-generate-once'; $ovo = new OVOID($deviceId); // 1) Request a code. The server sends either: // - SMS with a 6-digit code, or // - a magic link (WhatsApp/email) whose URL carries the code in its `?code=` query param. $otp = $ovo->auth->requestOtp('+62812xxxxxxx', $deviceId)['otp']; $otpRefId = $otp['otp_ref_id']; $otpType = $otp['type']; // MUST be echoed back in validateOtp() — see below // 2) Validate whatever the user received (the 6-digit SMS code, OR the `code` value from the link). $validated = $ovo->auth->validateOtp('+62812xxxxxxx', $deviceId, $code, $otpRefId, $otpType); // 3) Log in. `otp_token` and `otp_ref_id` come from validateOtp(). $login = $ovo->auth->loginWithPin( '+62812xxxxxxx', $pin, $deviceId, $validated['otp']['otp_token'], $validated['otp']['otp_ref_id'], ); // 4) Use the access token for everything else. $ovo->client->setAccessToken($login['auth']['access_token']); $balance = $ovo->balance->inquiryBalance();
Two delivery channels, one validation call. The server decides which channel to use; it is reported by
requestOtp()(andresolveOnboardingType()) viaotp.reff_type:"OTP"= SMS code,"LINK"= magic link. Both are validated with the samevalidateOtp()— you only change what you pass as the$code.
Echo
typeback intovalidateOtp(). The server rejects the call withOV00002 "type: non zero value required"when$typeis empty, so always pass through thetypereturned byrequestOtp()(values seen:LOGIN,CREATION, ...). Same forloginWithPin():push_notification_idmust be non-empty (the wrapper falls back todevice_idfor you).
resolveOnboardingType() is optional — it mirrors the app's phone-number dispatch so you
can peek at the account's channel before requesting a code:
$onboarding = $ovo->auth->resolveOnboardingType('+62812xxxxxxx', $deviceId); // $onboarding['next'] === 'PIN_ENTRY' | 'OTP_VERIFY' | 'MAGIC_LINK' | 'UNKNOWN'
You do not need it to log in: the requestOtp → validateOtp → loginWithPin sequence above
is sufficient. Note that for some accounts the onboardingType endpoint returns OV00013
("Anda Tidak Memiliki Akses"); that's a red herring for login — this library does not depend on
it as a prerequisite.
Feature examples
Everything below assumes you already have a session (see "Quick start — login"):
use Stelin\OVOID; $ovo = new OVOID($deviceId); $ovo->client->setAccessToken($accessToken); // from $login['auth']['access_token']
Balance
$balance = $ovo->balance->inquiryBalance(); // `wallet/inquiry` uses the OLDER `{status, data, message}` envelope, so read `['data']` yourself. $data = $balance['data'] ?? []; // OVO Cash ('001'), OVO Points ('600'), ... keyed by payment-method id: $ovoCash = $data['001']['card_balance'] ?? null; $ovoPoints = $data['600']['card_balance'] ?? null; echo "OVO Cash: {$ovoCash}, OVO Points: {$ovoPoints}";
History
$history = $ovo->history->getTransactionHistory(1, 10); // page 1, limit 10 $detail = $ovo->history->getTransactionDetail($merchantId, $merchantInvoice);
Transfer (read-only first)
// Always check validity/fees before executing anything: $bankList = $ovo->transfer->getBankList(); $inquiry = $ovo->transfer->inquiryTransfer($accountNo, $bankCode, $bankName, '50000', 'message'); $isOvo = $ovo->transfer->verifyCustomerIsOvo($mobileNumber, '50000', 'message');
Transfer (moves money)
// EXECUTES a real transfer. Throws AmountException if below the 10,000 minimum. $ovo->transfer->transferBankDirect( $accountName, $accountNo, $accountNoDestination, '50000', $bankCode, $bankName); $ovo->transfer->transferP2p('50000', $targetOvoMsisdn, $trxId);
⚠️ The
transferBankDirect()/transferP2p()methods move real money. Always call the read-onlyinquiryTransfer()/verifyCustomerIsOvo()first, and test against your own account.
Registration (new OVO account)
// Same shape as loginWithPin(), but type "CREATE" and optional full name. $register = $ovo->auth->registerWithPin( $msisdn, $pin, $deviceId, $validated['otp']['otp_token'], $validated['otp']['otp_ref_id'], fullName: 'Nama Pemilik', // optional );
Caching the session token
Logging in needs a fresh OTP each time, so you can cache the session to skip it while the token is still valid (~24 hours):
use Stelin\TokenCache; $auth = TokenCache::load(__DIR__ . '/.ovo-token.json'); if ($auth === null) { // ...OTP + loginWithPin() as above... TokenCache::save(__DIR__ . '/.ovo-token.json', $login['auth']); $auth = $login['auth']; } $ovo->client->setAccessToken($auth['access_token']);
TokenCachecomparesexpires_inagainst the current time as an absolute epoch timestamp (not as a duration) — see its PHPDoc for why.
TokenCache::loadPendingOtp() / savePendingOtp() do the same for an in-flight OTP request:
if requestOtp() hits the cooldown (OV00015) on a retry, the previous otp_ref_id — and
the SMS it already sent — are usually still valid. Save it after a successful requestOtp()
and fall back to it on cooldown instead of giving up:
try { $otp = $ovo->auth->requestOtp($msisdn, $deviceId)['otp']; TokenCache::savePendingOtp(__DIR__ . '/.ovo-otp-pending.json', $otp); } catch (ApiException $e) { $otp = TokenCache::loadPendingOtp(__DIR__ . '/.ovo-otp-pending.json', ignoreExpiry: true) ?? throw $e; // nothing to fall back to }
Services
Everything is exposed on one OVOID instance:
| Service | Methods | Effect |
|---|---|---|
$ovo->auth |
requestOtp(), validateOtp(), resolveOnboardingType(), loginWithPin(), registerWithPin(), stepUpInitiate(), verifyPin(), verifyOtp(), resendOtp() |
login / OTP / register / RBA step-up |
$ovo->balance |
inquiryBalance() |
read-only |
$ovo->history |
getTransactionHistory(), getTabunganHistory(), getPayLaterHistory(), getTransactionDetail(), getRecentTransactions(), getReceiptContent(), addFavoriteFromReceipt(), deleteRecentTransaction() |
read-only |
$ovo->transfer |
getBankList(), getTransferHistory(), inquiryTransfer(), verifyCustomerIsOvo(), getFavoriteTransfer(), addFavoriteBankTransfer(), addFavoriteP2pTransfer(), deleteFavoriteTransfer() |
read-only |
$ovo->transfer |
transferBankDirect(), transferP2p() |
EXECUTES a transfer |
$ovo->payment |
doQrPayment(), getPaymentMethod(), sendPayment(), getTip(), getCapPoint() |
payment / QR (⚠️ doQrPayment signature is experimental) |
$ovo->qris |
qrScanPay(), generateCheckoutData() |
read-only |
$ovo->checkout |
doCheckout(), getCheckoutDetail(), getPromos(), cancelPromo() |
merchant checkout |
$ovo->billpay |
getCategories(), getBillersByCategory(), inquiry(), payBill(), editFavorite(), ... |
bill payment |
$ovo->linkage |
getAllLinkages(), getTnc(), acceptTnc(), initiateLinkage(), linkPartnerAccount(), unlinkAccount() |
OAuth partner linkage |
$ovo->kyc |
getCustomerUpgradeStatus(), getKycStatus() |
read-only |
$ovo->withdrawal |
getWithdrawalSource(), getNominalSuggestions(), doWithdrawal(), generateWithdrawalCode(), getWithdrawalGuidance() |
cash out |
$ovo->topup |
getTopUpMenu(), getTopupDenom(), topUpDebitPrepare(), topupDebit() |
top-up (debit card) |
$ovo->topupPartner |
getStoreDetails(), generateTopUpPaymentCode(), getTopUpPaymentCode() |
top-up (voucher/agent) |
$ovo->security |
unlock(), unlockActionMark(), unlockAndValidateTrxId() |
wallet unlock / PIN re-validation |
Methods that move real money throw \Stelin\Exception\AmountException if the amount is below
OVO's minimum (10,000). Always call the read-only inquiryTransfer() / verifyCustomerIsOvo()
first, and test with your own account before relying on this in anything unattended.
Response envelope
Most endpoints wrap responses as {response_code, response_version, response_message, data} —
Client unwraps this and you get data back directly.
A few older endpoints use {status, data, message} instead (e.g. wallet/inquiry). Those do
not match the unwrap condition, so they're returned unmodified — read ['data'] yourself
for those specific calls (documented on the relevant method).
Errors
Every non-2xx API response throws \Stelin\Exception\ApiException, which carries the
machine-readable OVO error code:
use Stelin\Exception\ApiException; try { $ovo->auth->requestOtp($msisdn, $deviceId); } catch (ApiException $e) { // $e->responseCode: e.g. "OV00015" (rate limit), "OV00060" (invalid phone number) // $e->getMessage(): the human-readable message OVO sent // $e->payload: the full decoded response body // $e->httpStatus: the HTTP status code }
Known codes:
| Code | Meaning |
|---|---|
OV00002 |
field validation — message like "<field>: non zero value required" (empty type, otp, or push_notification_id in the login flow). Fill the field rather than retrying. |
OV00003 / OV00521 |
rate limit / cooldown (~30 min) |
OV00015 |
OTP cooldown (~60 s) |
OV00013 |
"Anda Tidak Memiliki Akses" — generic access-denied; often session-invalid or a pre-login endpoint rejecting this account/device. Not caused by headers anymore (client-id is correct). |
OV00060 |
invalid phone number |
Testing
composer install
composer test
Tests are pure unit tests (crypto round-trips, request/response shape assertions against a
fake HTTP client) — nothing hits the real API, so composer test is safe to run without
credentials.