Download the PHP package thales/wave without Composer
On this page you can find all versions of the php package thales/wave. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.
Package wave
Short Description A fully-typed PHP SDK for the Wave Business API, built on Saloon.
License MIT
Homepage https://github.com/Thales-Finances/wave-php
Informations about the package wave
thales/wave
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.
Installation
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
Laravel
The service provider is auto-discovered. Set your key and go:
Or inject the connector — it is bound as a singleton:
To customise anything else, publish the 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
Reading a session
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
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:
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:
Batches
Batch processing is asynchronous — createBatch() returns only an id, and the payouts
are not in that response. Poll for them:
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:
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.
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
@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
Balance
Transactions
transactions() walks every page lazily — nothing is fetched until you iterate, and only
one page is held at a time:
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:
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:
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
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.
Creating one, then transacting as it:
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.
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:
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:
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
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
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:
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:
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):
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.
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:
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:
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:
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:
The helper is public, and verification accepts multiple v1= values so a secret rotation does not
drop traffic:
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:
Testing
The package is Saloon-native, so MockClient works as usual:
Running the package's own suite:
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:
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.