Search by

theocode12 / wise-php-sdk

Theocode12

Unofficial Wise Platform PHP SDK with rich models and transport-agnostic architecture.

Package info

github.com/Theocode12/wise-php-sdk

pkg:composer/theocode12/wise-php-sdk

Statistics

Installs: 133

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.2.0 2026-10-27 16:15 UTC

README

Wise PHP SDK for Wise Platform APIs.

Tests Latest Version Total Downloads License

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 / clientSecret is 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.md
  • docs/transports/curl.md
  • docs/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 POST operations 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: true marks test messages sent while setting up a subscription.
  • Respond with a 2xx status 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 timeoutSeconds to 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 / correlationId from exceptions in logs and alerts.

Test Confidence

  • Unit tests cover request path/method/body contracts for implemented endpoints.
  • Fixtures in tests/Fixtures/wise are 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.5 is non-blocking.

Guides

  • docs/transports/guzzle.md
  • docs/transports/curl.md
  • docs/transports/laravel.md
  • docs/API_REFERENCE.md
  • docs/WISE_API_STATUS.md
  • docs/SANDBOX_CHECKS.md
  • docs/VERSIONING.md
  • RELEASE.md

FAQ

I get invalid_token. What should I check?

  • Match token type to environment (api.wise.com vs api.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.