Wise
Production-grade Elixir client for the Wise Platform API.
Features
- All 42 Wise API groups — Profiles, Quotes, Transfers, Balances, Cards, KYC, Webhooks, and more
- Three auth modes — Personal Token, OAuth 2.0 Client Credentials, OAuth 2.0 User Token
- OTP-backed transport — Token-bucket rate limiter (
GenServer), circuit breaker (GenServer) - Retry with crypto jitter — Exponential back-off using
:crypto.strong_rand_bytes/1 - Webhook verification — HMAC-SHA256 with constant-time comparison (no external deps)
- Request/response hooks — Telemetry, structured logging, custom headers
- Connection pooling — Hackney pool managed by
Wise.Application - Fully typed —
@spec,@type,Dialyxir-compatible throughout
Installation
def deps do
[{:wise, "~> 1.0"}]
endQuick Start
# Build a config
config = Wise.Config.new!(
personal_token: System.fetch_env!("WISE_API_TOKEN"),
sandbox: true
)
# Ping the API
:ok = Wise.ping(config)
# List profiles
{:ok, profiles} = Wise.Services.Profiles.list(config)
# Create a quote
{:ok, quote} = Wise.Services.Quotes.create(config, profile_id, %{
sourceCurrency: "USD",
targetCurrency: "GBP",
sourceAmount: 1000
})
# Create a recipient
{:ok, recipient} = Wise.Services.Recipients.create(config, %{
profile: profile_id,
accountHolderName: "Alice Smith",
currency: "GBP",
type: "sort_code",
details: %{sortCode: "040075", accountNumber: "12345678"}
})
# Create and fund a transfer
key = Wise.IdempotencyKey.new()
{:ok, transfer} = Wise.Services.Transfers.create(config, %{
targetAccount: recipient["id"],
quoteUuid: quote["id"],
customerTransactionId: key
})
{:ok, _} = Wise.Services.Transfers.fund(config, profile_id, transfer["id"])Error Handling
All service calls return {:ok, result} or {:error, %Wise.Error{}}:
case Wise.Services.Transfers.fund(config, profile_id, transfer_id) do
{:ok, result} ->
result
{:error, %Wise.Error{code: "SCA_REQUIRED"}} ->
redirect_to_sca_url()
{:error, %Wise.Error{type: :circuit_open, message: msg}} ->
Logger.warn("Circuit breaker open: #{msg}")
{:error, %Wise.Error{type: :network, message: msg}} ->
Logger.error("Network error: #{msg}")
{:error, err} when Wise.Error.server_error?(err) ->
Logger.error("Server error #{err.status_code}")
endError predicates
Wise.Error.not_found?(err) # true for HTTP 404
Wise.Error.sca_required?(err) # true for HTTP 403 + code "SCA_REQUIRED"
Wise.Error.rate_limited?(err) # true for HTTP 429
Wise.Error.unauthorized?(err) # true for HTTP 401
Wise.Error.server_error?(err) # true for HTTP 5xx
Wise.Error.network_error?(err) # true for transport-level failures
Wise.Error.circuit_open?(err) # true when circuit breaker rejected the request
Wise.Error.field_errors(err) # [%{field:, code:, message:}] from 422 responsesAuthentication
Personal API Token
config = Wise.Config.new!(personal_token: System.fetch_env!("WISE_API_TOKEN"))OAuth 2.0 — Client Credentials (auto-refresh)
config = Wise.Config.new!(
client_id: System.fetch_env!("WISE_CLIENT_ID"),
client_secret: System.fetch_env!("WISE_CLIENT_SECRET"),
sandbox: true
)OAuth 2.0 — User Token with refresh callback
config = Wise.Config.new!(
access_token: stored_token,
refresh_token: stored_refresh,
token_expires_at: stored_expiry,
on_token_refresh: fn refresh_token ->
case MyTokenStore.refresh(refresh_token) do
{:ok, new_token} -> {:ok, %{access_token: new_token.access, refresh_token: new_token.refresh, expires_at: new_token.expiry}}
err -> err
end
end
)Advanced Configuration
{:ok, rl} = Wise.Internal.RateLimiter.start_link(rate: 10, burst: 20)
{:ok, cb} = Wise.Internal.CircuitBreaker.start_link(
failure_threshold: 5,
success_threshold: 2,
timeout_ms: 30_000
)
config = Wise.Config.new!(
personal_token: token,
sandbox: true,
timeout: 30_000,
max_retries: 3,
retry_base_delay: 500,
rate_limiter: rl,
circuit_breaker: cb,
request_hooks: [fn headers -> [{"X-Custom", "value"} | headers] end],
response_hooks: [fn resp, latency_ms ->
:telemetry.execute([:wise, :request], %{latency: latency_ms}, %{status: resp.status_code})
end]
)Webhooks
# Subscription management
{:ok, sub} = Wise.Services.Webhooks.create(config, %{
name: "transfers-hook",
trigger_on: "transfers#state-change",
url: "https://yourapp.com/webhooks/wise",
profile_id: profile_id
})
# In your HTTP handler (Phoenix, Plug, etc.)
def handle_webhook(conn) do
body = conn.body_params |> Jason.encode!()
sig = get_req_header(conn, "x-signature-sha256") |> List.first("")
secret = System.fetch_env!("WISE_WEBHOOK_SECRET")
case Wise.Services.Webhooks.verify_and_parse(body, sig, secret) do
{:ok, %{"eventType" => "transfers#state-change", "data" => data}} ->
handle_transfer_state_change(data)
send_resp(conn, 200, "ok")
{:error, %Wise.Error{type: :invalid_signature}} ->
send_resp(conn, 401, "unauthorized")
end
endSimulations (Sandbox only)
# Advance a transfer through states
{:ok, _} = Wise.Services.Simulations.advance_transfer(config, transfer_id, "processing")
{:ok, _} = Wise.Services.Simulations.advance_transfer(config, transfer_id, "funds_converted")
{:ok, _} = Wise.Services.Simulations.advance_transfer(config, transfer_id, "outgoing_payment_sent")
# Simulate incoming payment
{:ok, _} = Wise.Services.Simulations.simulate_incoming_payment(config, profile_id, balance_id,
%{value: 1000.00, currency: "GBP"})All 42 API Groups
| Module | Description |
|---|---|
Wise.Services.Profiles | Personal & business profiles |
Wise.Services.Quotes | Rate locking & fee calculation |
Wise.Services.Recipients | Beneficiary account management |
Wise.Services.Transfers | Payment creation & funding |
Wise.Services.Balances | Multi-currency balances |
Wise.Services.Statements | JSON/CSV/PDF/XLSX statements |
Wise.Services.BankAccounts | Receive-money bank details |
Wise.Services.Batches | Batch payments (up to 1,000) |
Wise.Services.DirectDebits | ACH/EFT funding accounts |
Wise.Services.Rates | Exchange rates |
Wise.Services.Currencies | Supported currencies |
Wise.Services.Comparisons | Multi-provider price comparison |
Wise.Services.Cards | Card status & PCI-DSS sensitive data |
Wise.Services.CardOrders | Physical & virtual card ordering |
Wise.Services.CardTransactions | Card transaction history |
Wise.Services.SpendLimits | Per-card & per-profile limits |
Wise.Services.SpendControls | MCC & transaction-type controls |
Wise.Services.Disputes | Card transaction disputes |
Wise.Services.KioskCollection | On-site card production |
Wise.Services.PushProvisioning | Apple/Google Pay provisioning |
Wise.Services.ThreeDS | 3D Secure challenge results |
Wise.Services.Webhooks | Subscription management & HMAC verification |
Wise.Services.Activities | Profile activity log |
Wise.Services.Addresses | Profile address management |
Wise.Services.Contacts | Find profiles by Wisetag/email/phone |
Wise.Services.KYC | Evidence upload & verification |
Wise.Services.KYCReview | Hosted & API-based KYC reviews |
Wise.Services.OAuth | OAuth 2.0 token exchange & refresh |
Wise.Services.OTT | One Time Token SCA (deprecated, use SCA) |
Wise.Services.SCA | Strong Customer Authentication |
Wise.Services.Cases | Partner support case management |
Wise.Services.MCA | Multi Currency Account |
Wise.Services.Users | User account management |
Wise.Services.UserSecurity | PIN, FaceMap, phone & device setup |
Wise.Services.FaceTec | Biometric public key retrieval |
Wise.Services.JOSE | JWS/JWE key management & playground |
Wise.Services.ClaimAccount | Account claim code generation |
Wise.Services.Simulations | Sandbox state simulation |
Running Tests
mix deps.get
mix test
mix test --cover # with coverage
mix credo --strict # linting
mix dialyzer # type checkingLicense
MIT — see LICENSE.