kepson / wave-client
Laravel client that automates the Wave Business web dashboard by replaying its internal HTTP requests (session + OTP based).
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.8
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
This package is not auto-updated.
Last update: 2026-08-14 09:14:50 UTC
README
A Laravel package that automates the Wave Business dashboard by replaying its internal GraphQL requests — letting you fetch transaction history, send payouts, check balances, and more from your Laravel application without any manual browser interaction.
Note: This package mimics the private HTTP requests made by the
business.wave.comweb application. It is not based on the officialapi.wave.comREST API. Use responsibly and in accordance with Wave's terms of service.
Requirements
- PHP 8.2+
- Laravel 11, 12, or 13
- A Wave Business account with API/dashboard access
Installation
From Packagist (once published)
composer require kepson/wave-client
Configuration
Publish the config file:
php artisan vendor:publish --tag=wave-client-config
This creates config/wave-client.php. Configure your credentials in .env:
# GraphQL endpoint — country-specific prefix (ci = Côte d'Ivoire, sn = Senegal…) WAVE_API_URL=https://ci.mmapp.wave.com/a/business_graphql # Public dashboard origin (keep as-is) WAVE_BASE_URL=https://business.wave.com # Your Wave Business credentials WAVE_PHONE=+2250700000000 WAVE_PIN=0000 # A stable UUID identifying this "device" — generate once and keep it fixed WAVE_DEVICE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Session cache settings (optional) WAVE_SESSION_STORE=file # any Laravel cache driver WAVE_SESSION_KEY=wave-client:session WAVE_SESSION_TTL=3600 # seconds
Generating a device ID:
php artisan tinker --execute "echo \Illuminate\Support\Str::uuid();"
Set the output as WAVE_DEVICE_ID and never change it — Wave uses it to recognise your device across sessions.
Authentication
Wave Business uses a three-step login flow: phone → PIN → SMS code.
Step 1 — Initiate login
use Alal\WaveClient\Exceptions\OtpRequiredException; try { Wave::auth()->login( mobile: '+2250700000000', pin: '0000', ); } catch (OtpRequiredException $e) { // An SMS code has been sent to $e->mobile // Store $e->tokenId if you need it; the package caches it automatically echo "Code sent to {$e->mobile}"; }
login() always throws OtpRequiredException on success — it is the signal that the SMS was dispatched and you must collect the code.
Step 2 — Confirm the SMS code
Wave::auth()->confirmSms('123456'); // Session is now cached — subsequent calls are authenticated automatically
Via Artisan (interactive terminal)
php artisan wave:login
The command prompts for phone and PIN (if not set in .env), then asks for the SMS code interactively.
Session persistence
The authenticated session (sId, walletId, businessId) is stored in your Laravel cache and reused across requests until it expires (WAVE_SESSION_TTL). You do not need to log in on every request.
If the session expires, actions throw AuthenticationException — catch it to trigger a re-login flow:
use Alal\WaveClient\Exceptions\AuthenticationException; use Alal\WaveClient\Exceptions\OtpRequiredException; try { $txns = Wave::transactions()->list(); } catch (AuthenticationException $e) { // Session expired — re-trigger your login flow }
Logout
Wave::auth()->logout(); // clears the cached session
Usage
Transactions
// Current month (default) $transactions = Wave::transactions()->list(); // Custom date range and filters $transactions = Wave::transactions()->list([ 'start' => '2026-06-01', 'end' => '2026-06-30', 'limit' => 50, 'transactionType' => 'ALL', // 'ALL' | 'RECEIVED' | 'SENT' 'searchTerm' => null, 'customerMobileStr'=> '+2250700000000', 'includePending' => true, ]); // Each item is a TransactionData object foreach ($transactions as $txn) { echo $txn->type; // e.g. MerchantSaleEntry, PayoutTransferEntry… echo $txn->amount; // e.g. "5000" echo $txn->summary; // human-readable label echo $txn->date; // whenEntered ISO 8601 echo $txn->mobile; // counterparty phone (normalised across types) echo $txn->name; // counterparty name echo $txn->isPending; // bool echo $txn->isCancelled; // bool echo $txn->grossAmount; // before fees (MerchantSaleEntry, PayoutTransferEntry) echo $txn->feeAmount; // fee charged echo $txn->clientReference; // your reference (MerchantSaleEntry) // $txn->raw — full raw GraphQL response array for this entry } // Find a single transaction by ID $txn = Wave::transactions()->find('TXN_ID');
Balance (stub — wire your captured request)
$balance = Wave::balance()->get(); echo $balance->amount; // e.g. "125000" echo $balance->currency; // e.g. "XOF"
Payout (stub — wire your captured request)
$payout = Wave::payout()->send( mobile: '+2250700000000', amount: '5000', options: [ 'name' => 'John Doe', 'reference' => 'INV-2026-001', 'reason' => 'Salary June', ] ); echo $payout->id; echo $payout->status; // processing | succeeded | failed
Payment Request (stub — wire your captured request)
$request = Wave::paymentRequest()->create([ 'amount' => '10000', 'currency' => 'XOF', 'reference' => 'ORDER-42', 'description' => 'Payment for order #42', ]); echo $request->paymentUrl; // share this link with your customer echo $request->status; // pending | completed | cancelled
Exception Reference
| Exception | When thrown |
|---|---|
OtpRequiredException |
login() succeeded — SMS was sent. Carry on with confirmSms(). |
AuthenticationException |
No valid session exists, or the session expired. Re-run the login flow. |
WaveRequestException |
The HTTP request failed (non-2xx response or GraphQL error field). |
RateLimitException |
HTTP 429 — too many requests. Check $e->getMessage() for retry hint. |
All exceptions extend WaveRequestException, which exposes the raw $e->response (an Illuminate\Http\Client\Response) for debugging.
Testing
The package ships a WaveFake that swaps out the real client in tests — no network calls, no OTP, no credentials needed.
Basic usage
use Alal\WaveClient\Facades\Wave; // In your test $fake = Wave::fake();
Queueing canned responses
$fake->queueBalance([ 'balance' => '50000', 'currency' => 'XOF', ]); $fake->queuePayout([ 'id' => 'payout-001', 'status' => 'processing', 'amount' => '5000', 'currency' => 'XOF', 'mobile' => '+2250700000000', ]); $fake->queueTransactions([ [ 'id' => 'txn-001', '__typename' => 'MerchantSaleEntry', 'amount' => '3000', 'summary' => 'Sale', 'whenEntered' => '2026-06-10T14:00:00Z', 'isPending' => false, 'isCancelled' => false, ], ]); $fake->queuePaymentRequest([ 'id' => 'pr-001', 'status' => 'pending', 'amount' => '10000', 'currency' => 'XOF', 'payment_url' => 'https://pay.wave.com/pr-001', ]);
If no response is queued for an action, the fake returns a sensible default.
Assertions
// Assert an action was called (any args) $fake->assertSent('balance.get'); $fake->assertSent('payout.send'); $fake->assertSent('transactions.list'); $fake->assertSent('paymentRequest.create'); // Assert with argument inspection $fake->assertSent('payout.send', fn(array $args) => $args['mobile'] === '+2250700000000' && $args['amount'] === '5000' ); $fake->assertSent('auth.login', fn(array $args) => $args['phone'] === '+2250700000000' ); // Assert an action was NOT called $fake->assertNotSent('payout.send'); // Assert nothing at all was sent $fake->assertNothingSent();
Full test example (Pest)
it('sends a payout when a payment is approved', function () { $fake = Wave::fake(); $fake->queuePayout([ 'id' => 'p-1', 'status' => 'processing', 'amount' => '5000', 'currency' => 'XOF', 'mobile' => '+2250700000000', ]); // Call the service that internally uses Wave::payout()->send(...) app(PaymentService::class)->disburse($payment); $fake->assertSent('payout.send', fn(array $args) => $args['mobile'] === '+2250700000000' ); });
Full test example (PHPUnit)
public function test_balance_is_fetched_on_dashboard(): void { $fake = Wave::fake(); $fake->queueBalance(['balance' => '25000', 'currency' => 'XOF']); $response = $this->get('/dashboard'); $response->assertSee('25 000 XOF'); $fake->assertSent('balance.get'); }
Available action keys for assertions
| Key | Triggered by |
|---|---|
auth.login |
Wave::auth()->login() |
auth.confirmSms |
Wave::auth()->confirmSms() |
auth.logout |
Wave::auth()->logout() |
balance.get |
Wave::balance()->get() |
payout.send |
Wave::payout()->send() |
payout.find |
Wave::payout()->find() |
payout.search |
Wave::payout()->search() |
transactions.list |
Wave::transactions()->list() |
transactions.find |
Wave::transactions()->find() |
paymentRequest.create |
Wave::paymentRequest()->create() |
paymentRequest.find |
Wave::paymentRequest()->find() |
paymentRequest.cancel |
Wave::paymentRequest()->cancel() |
Wiring a new action from a captured request
- Open DevTools → Network → copy the request as cURL.
- Identify the GraphQL query string and variables.
- Add a
const QUERYand implement the method in the relevantsrc/Actions/class. - Map the response fields in the corresponding
src/Data/DTO. - Add a
queue*helper and tests inWaveFakeif needed.
The WaveConnector::graphql() method handles auth, headers, error mapping, and trace context automatically.
Running the package tests
cd wave-client composer install composer test
License
MIT — see LICENSE.