thales / wave
A fully-typed PHP SDK for the Wave Business API, built on Saloon.
Requires
- php: ^8.2
- saloonphp/pagination-plugin: ^2.3
- saloonphp/saloon: ^4.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0 || ^10.0
- pestphp/pest: ^3.0
- phpstan/phpstan: ^2.0
Suggests
- illuminate/support: Enables the optional Laravel bridge (service provider, config, facade).
- saloonphp/laravel-plugin: Adds Saloon's Laravel integration (recording, artisan commands).
This package is not auto-updated.
Last update: 2026-08-08 12:16:55 UTC
README
A fully-typed PHP SDK for the Wave Business API, built on Saloon. Readonly DTOs, typed error codes, cursor pagination, and HMAC request signing. Works in plain PHP; ships an optional Laravel bridge.
$wave = new Thales\Wave\WaveConnector($apiKey); $session = $wave->checkout()->create(new CreateCheckoutSessionData( amount: '1000', currency: Currency::XOF, successUrl: 'https://shop.example/thanks', errorUrl: 'https://shop.example/oops', clientReference: 'order-42', )); header('Location: '.$session->waveLaunchUrl);
Installation
composer require thales/wave
Requires PHP 8.2+ and Saloon 4.
Saloon 3 is deliberately not supported: every 3.x release carries three unpatched CVEs (insecure deserialization, path traversal, and SSRF via absolute-URL endpoint override), so Composer refuses to install it at all. v4.0.0 is the first fixed release.
Status
Complete — all five Wave Business API domains.
| Domain | Status |
|---|---|
| Checkout | ✅ all 6 endpoints |
| Payout | ✅ all 7 endpoints, plus the undocumented B2B payout |
| Balance & Reconciliation | ✅ all 3 endpoints |
| Aggregated Merchants | ✅ all 5 endpoints |
| Webhooks | ✅ all 6 events + HMAC verification |
The shared foundation — connector, auth, request signing, typed exceptions, cursor pagination — is done and used by every domain that follows.
Configuration
Plain PHP
use Thales\Wave\WaveConnector; $wave = new WaveConnector( apiKey: getenv('WAVE_API_KEY'), signingSecret: getenv('WAVE_SIGNING_SECRET') ?: null, // only if signing is enabled on the wallet defaultAggregatedMerchantId: null, // aggregators only );
Laravel
The service provider is auto-discovered. Set your key and go:
WAVE_API_KEY=wave_sn_prod_...
use Thales\Wave\Laravel\Facades\Wave; $session = Wave::checkout()->create($data);
Or inject the connector — it is bound as a singleton:
public function __construct(private readonly WaveConnector $wave) {}
To customise anything else, publish the config:
php artisan vendor:publish --tag=wave-config
| Key | Env | Purpose |
|---|---|---|
api_key |
WAVE_API_KEY |
From business.wave.com/dev-portal |
base_url |
WAVE_BASE_URL |
Override for a proxy; defaults to https://api.wave.com |
signing_secret |
WAVE_SIGNING_SECRET |
Outbound Wave-Signature. Set only once signing is enabled in the dev portal |
webhook_secret |
WAVE_WEBHOOK_SECRET |
Inbound webhook verification |
webhook_tolerance |
WAVE_WEBHOOK_TOLERANCE |
Max signature age in seconds, default 300 (Wave's own limit) |
aggregated_merchant_id |
WAVE_AGGREGATED_MERCHANT_ID |
Default merchant for aggregators |
Checkout
$wave->checkout()->create($data, $idempotencyKey); // CheckoutSession $wave->checkout()->find('cos-18qq25rgr100a'); // CheckoutSession $wave->checkout()->findByTransactionId('TAWFTGCESD7K'); // CheckoutSession $wave->checkout()->search('order-42'); // list<CheckoutSession> $wave->checkout()->refund('cos-18qq25rgr100a'); // void $wave->checkout()->expire('cos-18qq25rgr100a'); // void
Reading a session
$session->isPaid(); // funds landed — the only safe gate for fulfilment $session->isOpen(); // still payable $session->isExpired(); $session->paymentStatus; // PaymentStatus enum $session->checkoutStatus; // CheckoutStatus enum $session->whenCompleted; // ?DateTimeImmutable $session->lastPaymentError?->errorCode(); // ?ErrorCode
isComplete() is not isPaid(). checkout_status = complete only means the payer finished the
flow; the payment can still be processing. Gate fulfilment on isPaid().
Payout
$wave->payouts()->create($data, $idempotencyKey); // Payout $wave->payouts()->find('pt-185sewgm8100t'); // Payout $wave->payouts()->search('FAH.4827.1734'); // list<Payout> $wave->payouts()->createBatch([$a, $b, $c]); // string — the batch id $wave->payouts()->findBatch('pb-185skxq8g1006'); // PayoutBatch $wave->payouts()->reverse('pt-185sewgm8100t'); // void $wave->payouts()->verifyRecipient($data); // RecipientVerification $wave->payouts()->createB2B($data); // B2BPayout @experimental
A 200 is not a success
This is the one thing to get right. Wave reports a failed payout as HTTP 200, with the
failure in the body — so nothing throws and a naive try/catch sees a clean call:
$payout = $wave->payouts()->create($data); // does not throw if ($payout->isSucceeded()) { // money actually moved } elseif ($payout->isFailed()) { $payout->payoutError?->code(); // ?ErrorCode, e.g. RecipientLimitExceeded } elseif ($payout->isProcessing()) { // still in flight — re-check later, do not assume either outcome }
receiveAmount is what the recipient gets; the fee is charged to you on top.
totalDebited() adds them as integer minor units, so it never drifts:
$payout->receiveAmount; // '15000' $payout->fee; // '150' (null while still processing) $payout->totalDebited(); // '15150'
Batches
Batch processing is asynchronous — createBatch() returns only an id, and the payouts
are not in that response. Poll for them:
$batchId = $wave->payouts()->createBatch([ new CreatePayoutData('1000', Currency::XOF, '+221555110219', name: 'Fatou Ndiaye'), new CreatePayoutData('1200', Currency::XOF, '+221555110233', name: 'Moustapha Mbaye'), ]); $batch = $wave->payouts()->findBatch($batchId); $batch->isComplete(); // every payout attempted — NOT all succeeded $batch->succeeded(); // list<Payout> $batch->failed(); // list<Payout> ← the ones needing attention $batch->processing(); // list<Payout>
A batch can be complete with most of its payouts failed, so always look at
failed() rather than trusting the batch status.
Reversals
Wave allows a reversal for exactly 3 days from the payout's timestamp, fees included:
if ($payout->isReversible()) { $wave->payouts()->reverse($payout->id); }
Safe to retry — Wave's own idempotency means a second reversal of the same payout
succeeds without creating another transaction, so you cannot double-reverse by accident.
Past the window you get ErrorCode::PayoutReversalTimeLimitExceeded.
Verifying a recipient
Restricted access. Wave disables this endpoint by default and enables it per business against a documented compliance justification; without that you get a 403. It is also rate limited to 30 checks per phone number per 5 minutes, and exceeding that blocks the number for an hour.
$check = $wave->payouts()->verifyRecipient(new VerifyRecipientData( mobile: '+221761110010', name: 'Alice Adams', amount: '1000', // amount and currency must travel together, currency: Currency::XOF, // or withinLimits comes back null )); $check->looksSafe(); // false only if Wave actively disagreed $check->nameMatches(); // MATCH $check->nameMismatches(); // NO_MATCH — a real red flag
Every field is nullable and null means "not asked", never "no". A null nameMatch
means you sent no name; NAME_NOT_KNOWN means Wave holds no name. Neither is a mismatch,
which is why nameMatches() and nameMismatches() are separate questions rather than one
boolean.
B2B payouts
// Recipient receives exactly 100000; the fee is charged to you on top. $wave->payouts()->createB2B(new CreateB2BPayoutData('100000', Currency::XOF, 'am-…')); // You are debited exactly 100000; the fee comes out of the recipient's share. $wave->payouts()->createB2B(new CreateB2BPayoutData( '100000', Currency::XOF, 'am-…', FeePaymentMethod::RecipientPays, ));
@experimental — POST /v1/b2b/payout is not in Wave's public documentation. It is
included because it is in production use, and its contract here was derived from that
usage rather than a published spec. The response DTO requires only id and status and
exposes raw(), so an unannounced change cannot break hydration. Everything else in this
package maps to a documented endpoint.
Balance & Reconciliation
$wave->balance()->get(); // Balance $wave->balance()->transactions('2026-08-04'); // iterable<Transaction>, all pages $wave->balance()->transactionsPage('2026-08-04'); // TransactionPage, one page + cursor $wave->balance()->refundTransaction('T_VZSWJF5MMQ'); // void
Balance
$balance = $wave->balance()->get(); $balance->amount; // '10245' — a string, always $balance->currency; // 'XOF' $balance->minorUnits(); // 10245 $wave->balance()->get(includeSubaccounts: true); // roll in readable subaccounts
Transactions
transactions() walks every page lazily — nothing is fetched until you iterate, and only
one page is held at a time:
foreach ($wave->balance()->transactions('2026-08-04') as $transaction) { $transaction->amount; // '-99' (signed) $transaction->isDebit(); $transaction->minorUnits(); // -99 }
Omit the date for today. Pass a DateTimeInterface or a 'YYYY-MM-DD' string — anything
else is rejected before the request goes out, because Wave would quietly return a
different day and a silently wrong day is the worst outcome for reconciliation.
Two things that will bite a naive ledger
1. transactionId is not unique. A reversal reuses the id of the row it reverses.
Wave's own example feed contains T_2YJNPWMCIY twice — once as a payment, once as its
reversal. Keying on the id alone silently collapses them:
$transaction->dedupeKey(); // id + reversal flag + timestamp — actually unique
2. amount is already net of fee. It is the signed balance delta, not the gross
figure. A received payment of 100 with a fee of 1 comes back as amount: "99",
fee: "1"; a payout as amount: "-101", fee: "1". Adding or subtracting fee from
amount double-counts.
Also worth knowing: transactionType is nullable — Wave documents it as "can be empty"
and omits it entirely from its own examples — and fee/balance are nullable too, so one
sparse row cannot fail a whole day's reconciliation.
Resumable reconciliation
When the cursor has to outlive the process — a nightly job picking up where it stopped — drive the pages yourself:
$cursor = $store->get('wave.cursor'); do { $page = $wave->balance()->transactionsPage('2026-08-04', after: $cursor); foreach ($page->items as $transaction) { /* … */ } $cursor = $page->nextCursor(); // null on the last page $store->put('wave.cursor', $cursor); } while ($cursor !== null);
transactionPaginator() is also available if you want Saloon's paginator itself — for
collect(), setMaxPages(), or the raw responses. Note that iterating a paginator
directly yields Response objects rather than rows; that is Saloon's contract, and why
transactions() exists as the everyday path.
Refunds
$wave->balance()->refundTransaction('T_VZSWJF5MMQ');
Reverses a payment you received, fees included. No reason required. Idempotent on Wave's
side, so a retry cannot produce a second refund. An unknown id raises NotFoundException.
Aggregated Merchants
Merchant identities you transact under, each with its own name and fee structure.
Partner-only — Wave limits this API to selected aggregators. A key without access gets
ErrorCode::NoPermission (403). If your business is not an aggregator, skip this section.
$wave->aggregatedMerchants()->all(); // iterable<AggregatedMerchant>, all pages $wave->aggregatedMerchants()->page(); // AggregatedMerchantPage, one page + cursor $wave->aggregatedMerchants()->create($data); // AggregatedMerchant $wave->aggregatedMerchants()->find('am-7lks22ap113t4'); // AggregatedMerchant $wave->aggregatedMerchants()->update($id, $data); // AggregatedMerchant $wave->aggregatedMerchants()->delete($id); // void
Creating one, then transacting as it:
$merchant = $wave->aggregatedMerchants()->create(new AggregatedMerchantData( name: 'Moustaphas Groceries', // must be unique across your merchants businessDescription: 'A grocery store in the heart of the city.', businessType: BusinessType::Other, websiteUrl: 'https://groceries.example.com', managerName: 'Moustapha', )); $wave->checkout()->create(new CreateCheckoutSessionData( amount: '1000', currency: Currency::XOF, successUrl: 'https://…/ok', errorUrl: 'https://…/ko', aggregatedMerchantId: $merchant->id, ));
Or set it once on the connector and every Checkout/Payout call inherits it — see
aggregated_merchant_id in the config table above.
Merchants lock after review
Once Wave reviews a merchant and assigns its fee structures, the record locks. Updates then
fail with ErrorCode::RecordLocked (403); deletion still works.
if ($merchant->isEditable()) { /* safe to offer an edit form */ } $merchant->isLocked; // true once reviewed $merchant->hasFeeStructures(); // false until Wave assigns them
The fee structures are read-only — Wave sets them, and AggregatedMerchantData has no way
to pass one. Their names are not parallel, so decode them rather than parsing the strings:
$merchant->payoutFeeStructure?->percentage(); // 1.5 for one_fifty_bps $merchant->checkoutFeeStructure?->basisPoints(); // 150 — prefer this for exact arithmetic
Update is a full replacement
update() is a PUT that replaces the whole record: any field left null in the payload is
cleared, not left alone. To change one field, start from the current state:
$merchant = $wave->aggregatedMerchants()->find($id); $wave->aggregatedMerchants()->update( $id, $merchant->toRequestData(name: 'Moustaphas Candle Shop'), );
toRequestData() copies the merchant's current values and overrides only the arguments you
name. (It therefore cannot clear a field — construct AggregatedMerchantData directly for
that.)
These endpoints are not idempotent
Unlike every other write in this package, Wave does not offer an idempotency key here, so
none is sent. create() consequently opts out of the retry policy: with nothing to make
a retry safe, a retried create could produce a second merchant. update() and delete()
stay retryable, being idempotent by HTTP semantics.
A duplicate name raises a ValidationException carrying
ErrorCode::DuplicateAggregatedMerchantName.
Webhooks
Everything above asks Wave for state. Webhooks are how Wave tells you — and since a
checkout's payment_status can sit at processing, the completion event is the primary
success path, not an optional extra.
Verify, then read
use Thales\Wave\Data\Webhook\WebhookEvent; use Thales\Wave\Enums\WebhookEventType; $event = WebhookEvent::fromRequest( rawBody: $rawRequestBody, // the exact bytes — see the warning below signatureHeader: $waveSignature, // the Wave-Signature header secret: $webhookSecret, ); match ($event->type) { WebhookEventType::CheckoutSessionCompleted => $this->fulfil($event->checkout()), WebhookEventType::MerchantPaymentReceived => $this->credit($event->merchantPayment()), WebhookEventType::B2BPaymentReceived => $this->record($event->b2bPayment()), default => null, // acknowledge anything else };
fromRequest() verifies before it parses, so you cannot read a payload you haven't
authenticated. A bad signature, a missing header, a replayed timestamp, or an unconfigured
secret all raise InvalidWebhookSignatureException — reject with 403 and never act on it.
Pass the raw body. The signature covers the exact bytes Wave sent; decoding the JSON and
re-encoding it changes whitespace and key order, and the digest stops matching. This is the
single most common webhook bug, and there's a test pinning it. In Laravel that means
$request->getContent(), never $request->all().
Laravel
use Thales\Wave\Laravel\Http\Middleware\VerifyWaveSignature; Route::post('/wave/webhook', WebhookController::class) ->middleware(VerifyWaveSignature::class);
public function __invoke(Request $request) { $event = VerifyWaveSignature::event($request); // already verified and parsed ProcessWaveWebhook::dispatch($event->toArray()); return response()->noContent(); }
Two things to set up alongside it, both of which will otherwise bite you:
- Exclude the route from CSRF verification. Wave sends no CSRF token, so Laravel's
VerifyCsrfTokenrejects the POST before this middleware runs. Put the route outside thewebgroup, or add the path to$except. - Answer within 5 seconds. Wave's timeout. Verify, queue, return 2xx — don't process inline.
The middleware fails closed: missing header, bad signature, or no configured secret all give a 403, never a fall-through to "accept unverified".
Events and payloads
| Event | type |
Typed payload |
|---|---|---|
| Checkout paid | checkout.session.completed |
CheckoutEvent (full session) |
| Checkout failed | checkout.session.payment_failed |
CheckoutEvent (partial) |
| B2B received | b2b.payment_received |
B2BPaymentEvent |
| B2B failed | b2b.payment_failed |
B2BPaymentEvent (Wave publishes no example) |
| Customer paid | merchant.payment_received |
MerchantPaymentEvent |
| Portal test ping | test.test_event |
$event->data (no payload) |
CheckoutEvent is deliberately not the REST API's CheckoutSession, because the two
aren't the same shape: completed carries a full session, but payment_failed carries only
four fields and reports both statuses as "failed" — a value the Checkout API's own enum
doesn't document. Feeding that to CheckoutSession would throw. Only id is required on
CheckoutEvent; upgrade when the payload is complete:
$checkout = $event->checkout(); $checkout->isPaid(); $checkout->hasFailed(); $checkout->lastPaymentError?->errorCode(); // ?ErrorCode $checkout->toCheckoutSession(); // full DTO, or null if too partial $checkout->raw(); // anything unmodelled
An unknown event type is acknowledged, not rejected
Everywhere else in this package an unrecognised enum value throws. Here it must not: Wave
redelivers any non-2xx for three days, so an event type added after your installed
version would become a retry storm. $event->type is null for anything unrecognised and
$event->rawType keeps the original string:
if (! $event->isKnown()) { Log::info('Unrecognised Wave event', ['type' => $event->rawType]); return response()->noContent(); // 2xx — stop the retries }
Delivery is best effort
Wave is explicit that events may be missed, duplicated, or arrive out of order, with retries for up to three days. The SDK can't fix that for you, so:
- Deduplicate on
$event->idbefore acting. It's unique per event. - Treat the transactions feed as the source of truth. A missed webhook is why
balance()->transactions()exists — reconcile against it rather than trusting that every event arrived. - Never assume ordering. A
payment_failedfor one attempt can arrive after acompletedfor the retry.
Verifying by hand
If you're not on Laravel, Support\Signature is public and handles rotation (Wave sends two
v1= signatures while a secret is being rotated):
Signature::verify($header, $rawBody, $secret); // bool, never throws Signature::verify($header, $rawBody, $secret, 600); // wider tolerance
Amounts are strings
Wave transmits amounts as strings, and this package never converts one to a float — a single
round-trip through a binary float turns "1234567.89" into a value that no longer equals itself.
use Thales\Wave\Support\Amount; Amount::fromInt(1000); // '1000' Amount::fromMinorUnits(1050, Currency::GMD); // '10.50' Amount::toMinorUnits('10.10', Currency::GMD); // 1010 — exactly, not 1009
Request DTOs validate their amount on construction, so a malformed value fails locally instead of
costing a round-trip and a 400. XOF and UGX are zero-decimal and reject "10.50" outright.
Amounts you read follow a looser rule than amounts you send: a balance can be "0" and a
transaction amount is a signed delta like "-99", both of which are rejected as request amounts.
Amount::toMinorUnits() accepts them; Amount::validate() does not.
Error handling
Everything the SDK throws implements WaveException, so one catch covers the package:
use Thales\Wave\Enums\ErrorCode; use Thales\Wave\Exceptions\{WaveException, ValidationException, NotFoundException}; try { $session = $wave->checkout()->create($data); } catch (NotFoundException $e) { // 404 } catch (ValidationException $e) { $e->details(); // field-level failures from Wave } catch (WaveException $e) { report($e); }
HTTP failures are also typed by status — AuthenticationException (401), AuthorizationException
(403), NotFoundException (404), ValidationException (400/422), IdempotencyException (409),
RateLimitException (429), ServerException (5xx) — and each carries the Wave error code:
$e->errorCode(); // ?ErrorCode enum $e->rawCode(); // the string Wave sent, always available $e->is(ErrorCode::InsufficientFunds); $e->getStatus(); $e->getResponse(); // the Saloon Response
An error code this release does not know about leaves errorCode() null and keeps rawCode()
intact, so a new Wave code never masks the underlying failure.
Idempotency and retries
Every POST carries an Idempotency-Key, generated per request or supplied by you:
$wave->checkout()->create($data, idempotencyKey: 'order-42');
The connector retries 3 times with exponential backoff, but only on connection failures, 429, and 5xx — a 400 or 422 fails identically on a second attempt, so retrying it just delays the error. The key is resolved once when the request object is constructed, so all attempts of a retried request share one key. Without that, three retries of one checkout would create three sessions.
Request signing
Set a signing secret and every request gains a Wave-Signature: t=…,v1=… header, computed over the
exact bytes being transmitted:
$wave = new WaveConnector(apiKey: $key, signingSecret: $secret);
The helper is public, and verification accepts multiple v1= values so a secret rotation does not
drop traffic:
use Thales\Wave\Support\Signature; Signature::verify($request->header('Wave-Signature'), $rawRequestBody, $secret);
Pass the raw body. Decoding JSON and re-encoding it changes whitespace and key order, which changes the digest.
Pagination
Cursor pagination over Wave's page_info envelope, lazily:
foreach ($wave->paginate($request)->items() as $item) { // one page fetched at a time }
Testing
The package is Saloon-native, so MockClient works as usual:
use Saloon\Http\Faking\{MockClient, MockResponse}; $mock = new MockClient([ CreateCheckoutSessionRequest::class => MockResponse::make($sessionJson, 200), ]); $wave->withMockClient($mock)->checkout()->create($data);
Running the package's own suite:
composer test # pint --test, phpstan, pest
The suite is fully offline. A guarded smoke test can run against the real API — it creates a session and expires it, moving no money:
WAVE_API_KEY=wave_sn_prod_… vendor/bin/pest --group=live
Notes on the API
- Base URL is
https://api.wave.com; endpoints are written as full/v1/...paths, matching the docs. - Wave publishes no sandbox. Keys come from the dev portal and are scoped to one business wallet.
- Rate limiting, IP whitelisting, and request signing are all configured per wallet in the dev portal.
Enabling signing there makes every unsigned request fail, so set
signing_secretin the same deploy.
License
MIT. See LICENSE.md.