thales/wave

A fully-typed PHP SDK for the Wave Business API, built on Saloon.

Maintainers

Package info

github.com/Thales-Finances/wave-php

pkg:composer/thales/wave

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-08-07 15:22 UTC

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,
));

@experimentalPOST /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 VerifyCsrfToken rejects the POST before this middleware runs. Put the route outside the web group, 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->id before 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_failed for one attempt can arrive after a completed for 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_secret in the same deploy.

License

MIT. See LICENSE.md.