bankplanet9 / milkyway-payments
Official PHP client for the MilkyWay Payments API (/payments/v1) — initiate, quote, track, and cancel cross-bank payments.
Requires
- php: >=8.1
- ext-json: *
- brick/math: ^0.11 || ^0.12
- php-http/discovery: ^1.19
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- nyholm/psr7: ^1.8
- phpunit/phpunit: ^10.5 || ^11.0
This package is auto-updated.
Last update: 2026-07-27 12:14:19 UTC
README
Official PHP client for the MilkyWay Payments API (/payments/v1) — the
partner-facing API banks use to initiate, quote, track, and cancel cross-bank
payments.
Batteries included:
- Keycloak client-credentials auth with in-memory token caching and automatic
refresh (plus a one-shot refresh-and-replay on
401). - Retries with exponential backoff + jitter on transient failures (5xx, 408, network), while deterministic errors (400/401/402/404) are never retried.
- Typed models & exceptions — money is
brick/mathBigDecimal(neverfloat), status is a backedenum, and each HTTP error maps to a specific exception type. - Client-agnostic — built on PSR-18 (HTTP client) and PSR-17 (factories) with
php-http/discoveryauto-discovery, so you can plug in Guzzle, Symfony HttpClient, or anything else.
Requires PHP 8.1+.
Install
composer require bankplanet9/milkyway-payments
You also need a PSR-18 client and PSR-17 factories. If your project does not already provide them, install a pair the SDK can auto-discover, e.g.:
composer require guzzlehttp/guzzle nyholm/psr7
Quick start
use Bankplanet9\MilkywayPayments\MilkywayOptions; use Bankplanet9\MilkywayPayments\MilkywayPaymentsClient; use Bankplanet9\MilkywayPayments\Model\PayRequest; use Bankplanet9\MilkywayPayments\Model\PrecheckRequest; use Brick\Math\BigDecimal; $client = new MilkywayPaymentsClient(new MilkywayOptions( baseUrl: 'https://milkyway.stage.planet9.ae', tokenUrl: 'https://keycloak.ac8o.planet9.ae/realms/planet9-stage/protocol/openid-connect/token', clientId: 'your-client-id', // issued to your institution clientSecret: 'your-client-secret', )); // 1. Is the recipient bank's service online? $client->healthcheck('bank-beta', 'card-payout'); // 2. Quote the payment (FX markup + commission applied here). $quote = $client->precheck(new PrecheckRequest( thirdPartyIdDebit: 'bank-beta', serviceId: 'card-payout', recipientId: 'recipient-9999', amountCredit: BigDecimal::of('100.00'), currencyCredit: 'USD', )); printf("Rate %s, debit %s %s, commission %s\n", $quote->rate, $quote->amountDebit, $quote->currencyDebit, $quote->commission); // 3. Initiate the payment. Pass an idempotency key so retries are safe. $transactionId = $client->pay(new PayRequest( thirdPartyIdDebit: 'bank-beta', serviceId: 'card-payout', senderId: 'sender-0001', recipientId: 'recipient-9999', amountCredit: BigDecimal::of('100.00'), currencyCredit: 'USD', data: ['passport' => 'AA1234567'], ), idempotencyKey: bin2hex(random_bytes(16))); // 4. Poll until the payment reaches a terminal status. $result = $client->waitForCompletion($transactionId); printf("Final status: %s\n", $result->status->name);
Bring your own HTTP client
$client = new MilkywayPaymentsClient( options: $options, httpClient: $myPsr18Client, // used for API calls // tokenHttpClient: a SEPARATE plain client for the token endpoint (must not be // wrapped in retry/auth); defaults to its own discovered client. requestFactory: $myPsr17RequestFactory, streamFactory: $myPsr17StreamFactory, );
Money & precision
All monetary fields (amountCredit, amountDebit, rate, commission) are
Brick\Math\BigDecimal. The API encodes money as raw JSON numbers; the SDK reads
those values from their literal textual form and builds BigDecimal from the
string, so no binary-float rounding ever touches your amounts — and the original
scale (e.g. 100.00) is preserved on the way out, too.
Discovery
Before you pay, discover which recipient banks are reachable, what services they
offer, and exactly what data each service expects — no out-of-band lookups.
// List every destination, or pass an ISO 3166-1 alpha-2 country to filter. foreach ($client->discovery(country: 'TJ') as $destination) { printf("%s (%s)\n", $destination->partnerName ?? $destination->thirdPartyId, $destination->country); foreach ($destination->services as $service) { printf(" - %s via %s, pays out in %s\n", $service->serviceId, $service->payoutMethod, implode(', ', $service->payoutCurrencies ?? [])); } } // Fetch the Draft-2020 JSON Schema for a service's `data` payload. It is the same // schema the server enforces at submit, returned as an associative array. $schema = $client->serviceSchema('bank-beta', 'card-payout');
discovery() returns a list<Destination> (the destinations wrapper is unwrapped
for you); the per-service data schema is omitted from the list and served on
demand by serviceSchema(). Both are read-only GETs that still flow through the
same auth + retry pipeline as every other call.
The data field
Each service requires extra per-partner fields (sender name, document number,
birthday, …) in the data array. Which keys are required depends on your
serviceId and the recipient bank — look them up in the Услуги registry at
https://milkyway-docs.stage.planet9.ae. The server validates data against the
service's JSON Schema during precheck, so a missing field is rejected before any
money moves.
Errors
Every API error throws a subclass of MilkywayApiException (carrying
getStatusCode() and the server's message via getMessage() / getResponseBody()):
| HTTP | Exception | Meaning |
|---|---|---|
| 400 | MilkywayValidationException |
Bad request (invalid amount, missing field, unresolvable FX rate). |
| 401 | MilkywayAuthException |
Token missing/invalid (also thrown if token acquisition fails). |
| 402 | MilkywayExposureBlockedException |
Payment would breach a block-action exposure limit. |
| 404 | MilkywayNotFoundException |
Transaction not found or not owned by your institution. |
| 5xx | MilkywayServiceUnavailableException |
API or downstream recipient unavailable (retried automatically first). |
Retries & idempotency
Transient failures (5xx, 408, network exceptions) are retried automatically with
exponential backoff + jitter, tunable via MilkywayOptions
(maxRetries, retryBaseDelay, requestTimeout). Deterministic 4xx responses are
never retried.
pay() is only auto-retried when you supply an idempotencyKey — without one,
a retry could create a duplicate payment, so the SDK sends it exactly once.
Configuration
| Option | Default | Purpose |
|---|---|---|
baseUrl |
— (required) | Payments API base URL. |
tokenUrl |
— (required) | Keycloak token endpoint. |
clientId / clientSecret |
— (required) | Your institution's credentials. |
scope |
none | Optional OAuth scope. |
tokenRefreshSkew |
30s | Refresh this long before token expiry. |
requestTimeout |
30s | Per-attempt request timeout (enforce via your HTTP client). |
maxRetries |
3 | Max transient-failure retries. |
retryBaseDelay |
0.5s | Base delay for exponential backoff. |
Note: per-attempt timeout is honored by the underlying PSR-18 client — configure it on the client you pass in (e.g. Guzzle
timeout), since PSR-18 has no portable timeout API.
Building from source
composer install composer test # phpunit composer lint # php -l over all sources
Releasing
Releases are fully automated by semantic-release
on every push to main:
- Conventional commits are analysed (
feat:→ minor,fix:/perf:→ patch,!/BREAKING CHANGE→ major). No releasable commits → no release. - A GitHub release +
vX.Y.Ztag is created with generated notes. - Packagist picks up the new tag automatically and publishes the new version.
There is no build artifact and no registry token for PHP — the git tag is the release, and Packagist syncs from it via a GitHub webhook.
One-time Packagist setup (maintainers)
Do this once so step 3 above works:
- Sign in at https://packagist.org and Submit the repository URL
https://github.com/bankplanet9/milkyway-php-sdkto register thebankplanet9/milkyway-paymentspackage. - Enable auto-updates so each tag syncs automatically. Easiest: install the
Packagist GitHub application (Packagist → your package → Settings shows the
exact webhook/hook instructions), or add the GitHub service hook with your
Packagist API token. Once connected, every
vX.Y.Ztag pushed by semantic-release appears on Packagist within seconds.
No secrets are stored in CI; the release job only needs the default GITHUB_TOKEN.
License
MIT — see LICENSE. Copyright (c) 2026 Planet9.