PHPackages                             lintangtimur/ovoid - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [API Development](/categories/api)
4. /
5. lintangtimur/ovoid

ActiveLibrary[API Development](/categories/api)

lintangtimur/ovoid
==================

Unofficial OVO (ovo.id) API client

v4(2w ago)1632.1k92[11 issues](https://github.com/lintangtimur/ovoid/issues)MITPHPPHP ^8.1CI failing

Since Dec 11Pushed 2w ago13 watchersCompare

[ Source](https://github.com/lintangtimur/ovoid)[ Packagist](https://packagist.org/packages/lintangtimur/ovoid)[ RSS](/packages/lintangtimur-ovoid/feed)WikiDiscussions master Synced today

READMEChangelog (10)Dependencies (3)Versions (19)Used By (0)

[![](https://camo.githubusercontent.com/1eceea7f21864ae80a12bea2a0e558662343c9ab18ed8925448ab31c8ffec5aa/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6c696e74616e6774696d75722f6f766f69642e7376673f7374796c653d706f706f75742d737175617265)](https://packagist.org/packages/lintangtimur/ovoid)[![](https://camo.githubusercontent.com/62ba7eafd72064b94639ed80b73291ba4aa3e8d97e6880692700b04acd8c521a/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f6c696e74616e6774696d75722f6f766f69642e7376673f7374796c653d706f706f75742d737175617265)](https://github.com/lintangtimur/ovoid/blob/master/LICENSE)[![Packagist](https://camo.githubusercontent.com/378f14ea4bf7d5809042fecd0bdd631d8b4519cacd5e31f1a7136a3cacdfe722/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6c696e74616e6774696d75722f6f766f69643f7374796c653d666c61742d737175617265)](https://camo.githubusercontent.com/378f14ea4bf7d5809042fecd0bdd631d8b4519cacd5e31f1a7136a3cacdfe722/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f6c696e74616e6774696d75722f6f766f69643f7374796c653d666c61742d737175617265)

ovoid — Unofficial OVO API client for PHP
-----------------------------------------

[](#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
------------

[](#requirements)

- PHP 8.1+
- `ext-curl`, `ext-openssl`
- No other dependencies

```
composer require lintangtimur/ovoid
```

Quick start — login
-------------------

[](#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()` (and `resolveOnboardingType()`) via `otp.reff_type`: `"OTP"` = SMS code, `"LINK"` = magic link. Both are validated with the same `validateOtp()` — you only change what you pass as the `$code`.

> **Echo `type` back into `validateOtp()`.** The server rejects the call with `OV00002 "type: non zero value required"` when `$type` is empty, so always pass through the `type` returned by `requestOtp()` (values seen: `LOGIN`, `CREATION`, ...). Same for `loginWithPin()`: `push_notification_id` must be non-empty (the wrapper falls back to `device_id` for 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
----------------

[](#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)

```
$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)

```
$history = $ovo->history->getTransactionHistory(1, 10);            // page 1, limit 10
$detail  = $ovo->history->getTransactionDetail($merchantId, $merchantInvoice);
```

### Transfer (read-only first)

[](#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)

[](#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-only `inquiryTransfer()` / `verifyCustomerIsOvo()` first, and test against your own account.

### Registration (new OVO 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
-------------------------

[](#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']);
```

> `TokenCache` compares `expires_in` against 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
--------

[](#services)

Everything is exposed on one `OVOID` instance:

ServiceMethodsEffect`$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-validationMethods 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
-----------------

[](#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
------

[](#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:

CodeMeaning`OV00002`field validation — message like `": 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 numberTesting
-------

[](#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.

###  Health Score

62

—

FairBetter than 99% of packages

Maintenance95

Actively maintained with recent releases

Popularity38

Limited adoption so far

Community23

Small or concentrated contributor base

Maturity79

Established project with proven stability

 Bus Factor1

Top contributor holds 95.6% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~164 days

Recently: every ~483 days

Total

18

Last Release

15d ago

Major Versions

v1.2.4.5 → v3.12021-10-05

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/16686825?v=4)[MJ](/maintainers/lintangtimur)[@lintangtimur](https://github.com/lintangtimur)

---

Top Contributors

[![lintangtimur](https://avatars.githubusercontent.com/u/16686825?v=4)](https://github.com/lintangtimur "lintangtimur (86 commits)")[![apriady](https://avatars.githubusercontent.com/u/1451429?v=4)](https://github.com/apriady "apriady (1 commits)")[![decoderid](https://avatars.githubusercontent.com/u/75539501?v=4)](https://github.com/decoderid "decoderid (1 commits)")[![mukhlisakbr](https://avatars.githubusercontent.com/u/27577560?v=4)](https://github.com/mukhlisakbr "mukhlisakbr (1 commits)")[![pasya1912](https://avatars.githubusercontent.com/u/36727552?v=4)](https://github.com/pasya1912 "pasya1912 (1 commits)")

---

Tags

apiindonesiaovoidpayment-gatewaywrapper

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/lintangtimur-ovoid/health.svg)

```
[![Health](https://phpackages.com/badges/lintangtimur-ovoid/health.svg)](https://phpackages.com/packages/lintangtimur-ovoid)
```

###  Alternatives

[exsyst/swagger

A php library to manipulate Swagger specifications

35816.5M7](/packages/exsyst-swagger)[lucasdotvin/laravel-soulbscription

A straightforward interface to handle subscriptions and features consumption.

709209.3k](/packages/lucasdotvin-laravel-soulbscription)[pimax/fb-messenger-php

Facebook Messenger Bot PHP API

313188.5k2](/packages/pimax-fb-messenger-php)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
