theocode12 / wise-php-sdk
Unofficial Wise Platform PHP SDK with rich models and transport-agnostic architecture.
Requires
- php: ^8.2
- ext-openssl: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^1.1 || ^2.0
- psr/log: ^1.1 || ^2.0 || ^3.0
Requires (Dev)
- laravel/pint: ^1.18
- phpstan/phpstan: ^1.12
- phpunit/phpunit: ^10.5
Suggests
- ext-curl: Use with a custom Curl transport implementation.
- guzzlehttp/guzzle: Use with Psr18Transport by passing a PSR-18 client.
- http-interop/http-factory-guzzle: PSR-17 factories for Guzzle users.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-27 17:28:52 UTC
README
Wise PHP SDK for Wise Platform APIs.
Origin
This package is a maintained fork of sudiptpa/wise-php-sdk, created by
Sujip Thapa. It continues from upstream v0.1.0 under the package name theocode12/wise-php-sdk and the
Theocode\Wise namespace. To migrate from the original package, see UPGRADE.md.
Unofficial Disclaimer
This package is not affiliated with, endorsed by, or maintained by Wise.
Requirements
- PHP 8.2+
- A transport implementation of
Theocode\Wise\Contracts\TransportInterface
Installation
composer require theocode12/wise-php-sdk
Quick Start
use GuzzleHttp\Client; use Http\Factory\Guzzle\RequestFactory; use Http\Factory\Guzzle\StreamFactory; use Theocode\Wise\Config\ClientConfig; use Theocode\Wise\Transport\Psr18Transport; use Theocode\Wise\Wise; $config = ClientConfig::apiToken('your-wise-api-token', ClientConfig::SANDBOX_BASE_URL); $transport = new Psr18Transport(new Client()); $wise = Wise::client($config, $transport, new RequestFactory(), new StreamFactory()); $profiles = $wise->profile()->list(); $firstProfileId = $profiles->all()[0]->id ?? null; $rates = $wise->rate()->list(); $balances = $wise->balance()->list((int) $firstProfileId);
Available Resources
$wise->profile()$wise->contact()$wise->currencies()$wise->address()$wise->quote()$wise->recipientAccount()$wise->transfer()$wise->payment()$wise->webhook()$wise->activity()$wise->balance()$wise->rate()$wise->balanceStatement()$wise->bankAccountDetails()$wise->user()$wise->userTokens()
Configuration
use Theocode\Wise\Config\ClientConfig; $api = ClientConfig::apiToken('your-wise-api-token'); $apiSandbox = ClientConfig::apiToken('your-wise-api-token', ClientConfig::SANDBOX_BASE_URL); $oauth = ClientConfig::oauth2('oauth-access-token'); $oauthSandbox = ClientConfig::oauth2('oauth-access-token', ClientConfig::SANDBOX_BASE_URL);
Base URLs:
- Production:
https://api.wise.com - Sandbox:
https://api.wise-sandbox.com
Auth Modes
| Mode | Use case | Credential | Token handling | Notes |
|---|---|---|---|---|
| API Token | Automating your own Wise account | Personal/Business API token | Managed by you | Best fit for single-account access; do not assume API funding is available |
| OAuth2 | Wise Platform / partner integrations | OAuth2 access token | Refresh flow in your app | Required for partner-style flows and connected-account access |
See the full auth capability guide:
docs/AUTH_CAPABILITIES.md
Auth Capability Summary
| Capability | Personal API Token | OAuth2 |
|---|---|---|
| Read your own account data | Yes | Yes |
| Create quotes and transfer drafts | Yes | Yes |
| Fund transfers through API | Limited and not guaranteed | Yes, in partner setups |
| Manage other Wise accounts | No | Yes, in partner setups |
Use /oauth/token app credentials flow |
No | Yes |
Notes:
- Personal API tokens are for your own Wise account.
- OAuth2 with
clientId/clientSecretis the Wise partner path. - If your profile is in the UK or EEA, do not rely on personal-token funding by API.
- Outside the UK/EEA, funding can still depend on your account setup. Check with Wise if this matters for your use case.
- If you only need self-account automation, start with personal token support.
- If you need delegated account access or API funding flows, plan for OAuth2.
If you rotate OAuth2 tokens, provide your own token provider:
use Theocode\Wise\Auth\AuthMode; use Theocode\Wise\Config\ClientConfig; use Theocode\Wise\Contracts\AccessTokenProviderInterface; final class OAuthProvider implements AccessTokenProviderInterface { public function getAccessToken(): string { return 'fresh-access-token'; } } $config = new ClientConfig( authMode: AuthMode::OAuth2, accessTokenProvider: new OAuthProvider(), baseUrl: ClientConfig::DEFAULT_BASE_URL, );
OAuth tokens (/oauth/token)
Every token request sends your client_id:client_secret as HTTP Basic auth. The client secret is never put in the form body.
use Theocode\Wise\Resources\UserTokens\Requests\CreateUserTokenRequest; // Application-level client credentials token (valid 12 hours, no refresh token) $token = $wise->userTokens()->create(CreateUserTokenRequest::clientCredentials($clientId, $clientSecret)); // User access tokens $token = $wise->userTokens()->create(CreateUserTokenRequest::authorizationCode($clientId, $clientSecret, $code, $redirectUri)); $token = $wise->userTokens()->create(CreateUserTokenRequest::registrationCodeForEmail($clientId, $clientSecret, $email, $registrationCode)); $token = $wise->userTokens()->create(CreateUserTokenRequest::refreshToken($clientId, $clientSecret, $refreshToken)); $token->accessToken; $token->expiresAt(); // expires_at, or now + expires_in
To call application-level endpoints with a client credentials token, use ClientCredentialsTokenProvider. It caches the token in memory and fetches a new one shortly before it expires:
use Theocode\Wise\Auth\AuthMode; use Theocode\Wise\Auth\ClientCredentialsTokenProvider; use Theocode\Wise\Config\ClientConfig; use Theocode\Wise\Wise; // The token client only sends the unauthenticated /oauth/token request. $tokenClient = Wise::client( new ClientConfig(authMode: AuthMode::OAuth2, baseUrl: ClientConfig::SANDBOX_BASE_URL), $transport, new RequestFactory(), new StreamFactory(), ); $wise = Wise::client( new ClientConfig( authMode: AuthMode::OAuth2, accessTokenProvider: new ClientCredentialsTokenProvider($tokenClient, $clientId, $clientSecret), baseUrl: ClientConfig::SANDBOX_BASE_URL, ), $transport, new RequestFactory(), new StreamFactory(), );
The cache lives only for the lifetime of the provider object. To share tokens across processes, store them in your own cache.
Transport Options (Choose One)
The SDK does not pick a transport for you.
1) PSR-18 + Guzzle
Install optional dependencies:
composer require guzzlehttp/guzzle http-interop/http-factory-guzzle
use GuzzleHttp\Client; use Http\Factory\Guzzle\RequestFactory; use Http\Factory\Guzzle\StreamFactory; use Theocode\Wise\Transport\Psr18Transport; use Theocode\Wise\Wise; $transport = new Psr18Transport(new Client()); $wise = Wise::client($config, $transport, new RequestFactory(), new StreamFactory());
2) Curl transport (example)
final class CurlTransport implements \Theocode\Wise\Contracts\TransportInterface { public function send(\Psr\Http\Message\RequestInterface $request): \Psr\Http\Message\ResponseInterface { // Run curl and map response to PSR-7. } }
3) Laravel transport (example)
final class LaravelTransport implements \Theocode\Wise\Contracts\TransportInterface { public function send(\Psr\Http\Message\RequestInterface $request): \Psr\Http\Message\ResponseInterface { // Call Laravel HTTP client and map response to PSR-7. } }
See full transport setup guides:
docs/transports/guzzle.mddocs/transports/curl.mddocs/transports/laravel.md
Send Money in 4 Steps
use Theocode\Wise\Resources\Payment\Requests\FundTransferRequest; use Theocode\Wise\Resources\Quote\Requests\CreateAuthenticatedQuoteRequest; use Theocode\Wise\Resources\RecipientAccount\Requests\CreateRecipientAccountRequest; use Theocode\Wise\Resources\Transfer\Requests\CreateTransferRequest; $quote = $wise->quote()->createAuthenticated( 123, CreateAuthenticatedQuoteRequest::fixedTarget('USD', 'EUR', 100) ); $recipient = $wise->recipientAccount()->create( new CreateRecipientAccountRequest(123, 'Jane Doe', 'EUR', 'iban', ['iban' => 'DE123']) ); $transfer = $wise->transfer()->create(CreateTransferRequest::from($quote, $recipient)); $payment = $wise->payment()->fundTransfer(123, $transfer->id, new FundTransferRequest('BALANCE'));
Important:
- The funding step is not the same as creating a draft transfer.
- If your profile is in the UK or EEA, do not rely on personal-token funding by API.
- Outside the UK/EEA, check with Wise if API funding is important for your flow.
- In most cases, personal-token users will create the transfer draft by API and complete funding in Wise web or mobile.
- OAuth2 partner environments are the expected path for API funding.
Recipient Account Requirements (Dynamic Forms)
Recipient details differ per currency, country, and legal type, so avoid hard-coding them. Use the account-requirements endpoints to build the form dynamically:
use Sujip\Wise\Resources\RecipientAccount\Requests\AccountRequirementsOptions; use Sujip\Wise\Resources\RecipientAccount\Requests\CreateRecipientAccountRequest; $requirements = $wise->recipientAccount()->requirementsForQuote( 'quote-id', new AccountRequirementsOptions(originatorLegalEntityType: 'PRIVATE', addressRequired: true) ); foreach ($requirements->all() as $requirement) { echo $requirement->title.PHP_EOL; foreach ($requirement->fields() as $field) { echo $field->key.': '.$field->name.PHP_EOL; foreach ($field->valuesAllowed ?? [] as $allowed) { echo ' '.$allowed->key.' = '.$allowed->name.PHP_EOL; } } } // After the user sets a field flagged refreshRequirementsOnChange=true, re-post the // same recipient payload you would send to /v1/accounts to discover any extra fields. $refreshed = $wise->recipientAccount()->refreshRequirementsForQuote( 'quote-id', new CreateRecipientAccountRequest( profile: 123, accountHolderName: 'Jane Doe', currency: 'EUR', type: 'iban', details: ['iban' => 'DE89370400440532013000', 'address.country' => 'US'], ) );
Activity Listing + Pagination
use Theocode\Wise\Resources\Activity\Requests\ListActivitiesRequest; $page = $wise->activity()->list(123, new ListActivitiesRequest(status: 'COMPLETED', size: 20)); foreach ($page->activities as $activity) { echo $activity->status().' - '.$activity->titlePlainText().PHP_EOL; } while ($page->hasNext()) { $page = $wise->activity()->list(123, new ListActivitiesRequest(nextCursor: $page->nextCursor(), size: 20)); }
Or iterate through all pages:
foreach ($wise->activity()->iterate(123, new ListActivitiesRequest(size: 50)) as $activity) { echo $activity->titlePlainText().PHP_EOL; }
Finding Your Profile ID
curl -sS https://api.wise-sandbox.com/v2/profiles \ -H "Authorization: Bearer <TOKEN>" \ -H "Accept: application/json"
Use the id field from the response.
member id is different from profile id.
Error Handling
use Theocode\Wise\Exceptions\ApiException; use Theocode\Wise\Exceptions\AuthException; use Theocode\Wise\Exceptions\RateLimitException; try { $quote = $wise->quote()->get(123, 456); } catch (AuthException $e) { // 401/403 } catch (RateLimitException $e) { // 429, retry delay in $e->retryAfter (seconds) } catch (ApiException $e) { // Other 4xx/5xx, payload in $e->errorBody }
Error Map
| HTTP status | Exception | Typical action |
|---|---|---|
| 401 / 403 | AuthException |
Check token type, token value, and scope |
| 429 | RateLimitException |
Wait and retry using retryAfter |
| 4xx / 5xx | ApiException |
Check error payload and request IDs |
| Transport failure | TransportException |
Check connectivity and transport implementation |
Retry and Idempotency
Retries are off by default. Enable them explicitly:
use Theocode\Wise\Auth\AuthMode; use Theocode\Wise\Auth\StaticAccessTokenProvider; use Theocode\Wise\Config\ClientConfig; $config = new ClientConfig( authMode: AuthMode::ApiToken, accessTokenProvider: new StaticAccessTokenProvider('your-token'), baseUrl: ClientConfig::DEFAULT_BASE_URL, retryEnabled: true, retryMaxAttempts: 4, retryBaseDelayMs: 200, retryMaxDelayMs: 2000, retryMethods: ['GET', 'POST'], idempotencyKey: 'your-stable-idempotency-key', );
Notes:
- Retry middleware applies only when enabled.
- It retries 429 and selected 5xx responses.
- Use idempotency keys for retryable write operations.
- Idempotency key is attached to SDK
POSToperations when configured.
Webhooks
Create subscriptions through WebhookResource. Wise expects one trigger per subscription and a
delivery block, which CreateWebhookSubscriptionRequest::for() builds:
use Theocode\Wise\Resources\Webhook\Requests\CreateWebhookSubscriptionRequest; $subscription = $wise->webhook()->createApplicationSubscription( 'your-client-key', CreateWebhookSubscriptionRequest::for( name: 'Transfers Hook', triggerOn: 'transfers#state-change', url: 'https://example.com/webhooks/wise', ), );
Wise signs each event with an RSA private key (SHA-256 digest of the raw body, Base64 encoded in the
X-Signature-SHA256 header) and publishes the matching public keys. Verify the raw request body
before processing:
use Theocode\Wise\Resources\Webhook\WebhookSignatureVerifier; $rawBody = file_get_contents('php://input') ?: ''; $signature = $_SERVER['HTTP_X_SIGNATURE_SHA256'] ?? ''; $verifier = WebhookSignatureVerifier::sandbox(); // WebhookSignatureVerifier::production() for live traffic if (! $verifier->verify($rawBody, $signature)) { http_response_code(401); exit('Invalid signature'); }
- Verify the body exactly as received: decoding and re-encoding the JSON changes the signed bytes.
- Pass several PEM keys (
new WebhookSignatureVerifier([$oldPem, $currentPem])) to support Wise key rotation. X-Test-Notification: truemarks test messages sent while setting up a subscription.- Respond with a
2xxstatus within 5 seconds; Wise retries failed deliveries. WebhookVerifier(HMAC with a shared secret) is deprecated because Wise does not use HMAC signatures.
Replay protection helper, keyed by the unique X-Delivery-Id header and the event sent_at timestamp:
use Theocode\Wise\Resources\Webhook\WebhookReplayProtector; use Theocode\Wise\Support\InMemoryWebhookReplayStore; $event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); $replayProtector = new WebhookReplayProtector(new InMemoryWebhookReplayStore(), 300); $replayProtector->validate( $_SERVER['HTTP_X_DELIVERY_ID'] ?? '', (int) strtotime((string) ($event['sent_at'] ?? '')), );
Redis replay store example:
use Theocode\Wise\Contracts\WebhookReplayStoreInterface; final class RedisWebhookReplayStore implements WebhookReplayStoreInterface { public function __construct(private \Redis $redis) {} public function remember(string $eventId, int $ttlSeconds): bool { return (bool) $this->redis->set("wise:webhook:{$eventId}", '1', ['nx', 'ex' => $ttlSeconds]); } }
Production Checklist
- Set
timeoutSecondsto a value suitable for your runtime and workload. - Keep retries off by default; enable only with explicit retry methods and limits.
- Use idempotency keys for retryable write flows.
- Rotate API/OAuth credentials and never store them in source control.
- Verify webhook signatures and enforce replay checks in persistent storage.
- Track
requestId/correlationIdfrom exceptions in logs and alerts.
Test Confidence
- Unit tests cover request path/method/body contracts for implemented endpoints.
- Fixtures in
tests/Fixtures/wiseare used for deterministic model hydration tests. - Middleware tests cover retry, idempotency behavior, and logging sanitization.
- Sandbox workflow provides scheduled live verification against Wise sandbox.
Production Checklist
- Set timeout and connect-timeout values.
- Use structured logging and keep secrets redacted.
- Rotate API/OAuth credentials.
- Use idempotency for retryable writes.
- Log request/correlation IDs for support.
- Monitor auth, rate-limit, and server error rates.
Versioning and Compatibility
- SemVer.
- Runtime target: PHP
^8.2. - CI runs on
8.2,8.3,8.4;8.5is non-blocking.
Guides
docs/transports/guzzle.mddocs/transports/curl.mddocs/transports/laravel.mddocs/API_REFERENCE.mddocs/WISE_API_STATUS.mddocs/SANDBOX_CHECKS.mddocs/VERSIONING.mdRELEASE.md
FAQ
I get invalid_token. What should I check?
- Match token type to environment (
api.wise.comvsapi.wise-sandbox.com). - Confirm token is active and complete.
- Confirm scope/profile access.
Is profile ID the same as member ID?
No. Use id from /v2/profiles.
Can I use live personal token in CI?
Not recommended. Use sandbox credentials in CI.
Why does SDK require transport + PSR-17 factories?
The SDK stays transport-agnostic. You bring the HTTP stack.
Quality
composer qa
API Reference
See docs/API_REFERENCE.md.
Sandbox Checks
See docs/SANDBOX_CHECKS.md.