TokenioClient
Production-grade Elixir client for the Token.io Open Banking platform.
Covers all 16 APIs from reference.token.io with full type safety, automatic OAuth2 token management, retry with jitter, telemetry, and HMAC webhook verification.
Installation
# mix.exs
def deps do
[{:tokenio_client, "~> 1.0"}]
endQuick Start
# Create a client (OAuth2)
{:ok, client} = TokenioClient.new(
client_id: System.fetch_env!("TOKENIO_CLIENT_ID"),
client_secret: System.fetch_env!("TOKENIO_CLIENT_SECRET")
# environment: :sandbox ← default
# environment: :production
)
# Initiate a payment
{:ok, payment} = TokenioClient.Payments.initiate(client, %{
bank_id: "ob-modelo",
amount: %{value: "10.50", currency: "GBP"},
creditor: %{account_number: "12345678", sort_code: "040004", name: "Acme Ltd"},
remittance_information_primary: "Invoice INV-2024-001",
callback_url: "https://yourapp.com/payment/return",
return_refund_account: true
})
# Handle the auth flow
if TokenioClient.Payments.Payment.requires_redirect?(payment) do
redirect_to(payment.redirect_url)
end
# Poll to final status (prefer webhooks in production)
{:ok, final} = TokenioClient.Payments.poll_until_final(client, payment.id,
interval_ms: 2_000,
timeout_ms: 60_000
)API Coverage
| Module | Endpoints |
|---|---|
TokenioClient.Payments | initiate, get, list, get_with_timeout, provide_embedded_auth, generate_qr_code, poll_until_final |
TokenioClient.VRP | create_consent, get_consent, list_consents, revoke_consent, list_consent_payments, create_payment, get_payment, list_payments, confirm_funds |
TokenioClient.AIS | list_accounts, get_account, list_balances, get_balance, list_transactions, get_transaction, list_standing_orders, get_standing_order |
TokenioClient.Banks | list_v1, list_v2, list_countries |
TokenioClient.Refunds | initiate, get, list |
TokenioClient.Payouts | initiate, get, list |
TokenioClient.Settlement | create_account, list_accounts, get_account, list_transactions, get_transaction, create_rule, list_rules, delete_rule |
TokenioClient.Transfers | redeem, get, list |
TokenioClient.Tokens | list, get, cancel |
TokenioClient.TokenRequests | store, get, get_result, initiate_bank_auth |
TokenioClient.AccountOnFile | create, get, delete |
TokenioClient.SubTPPs | create, list, get, delete |
TokenioClient.AuthKeys | submit, list, get, delete |
TokenioClient.Reports | list_bank_statuses, get_bank_status |
TokenioClient.Webhooks | set_config, get_config, delete_config, parse, typed decoders |
TokenioClient.Verification | initiate |
Variable Recurring Payments (VRP)
# 1. Create consent
{:ok, consent} = TokenioClient.VRP.create_consent(client, %{
bank_id: "ob-modelo",
currency: "GBP",
creditor: %{account_number: "12345678", sort_code: "040004", name: "Acme"},
maximum_individual_amount: "500.00",
periodic_limits: [
%{maximum_amount: "1000.00", period_type: "MONTH", period_alignment: "CALENDAR"}
],
callback_url: "https://yourapp.com/vrp/return"
})
# 2. Redirect PSU
if TokenioClient.VRP.Consent.requires_redirect?(consent) do
redirect_to(consent.redirect_url)
end
# 3. Check funds (optional)
{:ok, available} = TokenioClient.VRP.confirm_funds(client, consent.id, "49.99")
# 4. Initiate a payment once AUTHORIZED
{:ok, payment} = TokenioClient.VRP.create_payment(client, %{
consent_id: consent.id,
amount: %{value: "49.99", currency: "GBP"},
remittance_information_primary: "Subscription Jan 2025"
})Account Information Services (AIS)
{:ok, %{accounts: accounts}} = TokenioClient.AIS.list_accounts(client, limit: 50)
for account <- accounts do
{:ok, balance} = TokenioClient.AIS.get_balance(client, account.id)
IO.puts("#{account.display_name}: #{balance.current.value} #{balance.current.currency}")
end
{:ok, %{transactions: txns}} = TokenioClient.AIS.list_transactions(client, account.id, limit: 20)Webhooks
# Register your endpoint
:ok = TokenioClient.Webhooks.set_config(client, "https://yourapp.com/webhooks/tokenio_client",
events: ["payment.completed", "vrp.completed", "refund.completed"]
)
# In your Plug/Phoenix controller
def handle_webhook(conn) do
{:ok, body, conn} = Plug.Conn.read_body(conn)
sig = Plug.Conn.get_req_header(conn, "x-token-signature") |> List.first()
secret = System.fetch_env!("TOKENIO_WEBHOOK_SECRET")
case TokenioClient.Webhooks.parse(body, sig, webhook_secret: secret) do
{:ok, %{type: "payment.completed"} = event} ->
data = TokenioClient.Webhooks.decode_payment_data(event)
handle_payment_completed(data.payment_id, data.status)
send_resp(conn, 200, "ok")
{:ok, %{type: "vrp.completed"} = event} ->
data = TokenioClient.Webhooks.decode_vrp_data(event)
handle_vrp_completed(data.vrp_id)
send_resp(conn, 200, "ok")
{:error, :invalid_signature} ->
conn |> send_resp(401, "Unauthorized") |> halt()
{:error, :stale_timestamp} ->
conn |> send_resp(400, "Stale payload") |> halt()
end
endError Handling
All API functions return {:ok, result} or {:error, %TokenioClient.Error{}}.
case TokenioClient.Payments.get(client, payment_id) do
{:ok, payment} ->
payment
{:error, %TokenioClient.Error{code: :not_found}} ->
nil
{:error, %TokenioClient.Error{code: :rate_limit_exceeded, retry_after: ra}} ->
Process.sleep((ra || 5) * 1_000)
TokenioClient.Payments.get(client, payment_id)
{:error, %TokenioClient.Error{} = err} ->
Logger.error("Token.io error: #{Exception.message(err)}")
{:error, err}
endError predicates
alias TokenioClient.Error
Error.not_found?(err) # true for 404
Error.unauthorized?(err) # true for 401
Error.rate_limited?(err) # true for 429
Error.retryable?(err) # true for 429, 500, 502, 503, 504Configuration
{:ok, client} = TokenioClient.new(
client_id: "...",
client_secret: "...",
environment: :production, # :sandbox | :production (default: :sandbox)
timeout: 30_000, # ms (default: 30_000)
max_retries: 3, # default: 3
retry_wait_min: 500, # ms (default: 500)
retry_wait_max: 5_000 # ms (default: 5_000)
)
# Static token (bypass OAuth2 — useful for testing)
{:ok, client} = TokenioClient.new(static_token: "Bearer xyz")
# Custom base URL (for test mocks)
{:ok, client} = TokenioClient.new(static_token: "test", base_url: "http://localhost:4000")Application config (optional)
# config/runtime.exs
config :tokenio_client,
pool_size: 20,
pool_count: 2Telemetry
# Attach in your application startup
:telemetry.attach_many(
"tokenio_client-telemetry",
[
[:tokenio_client, :request, :start],
[:tokenio_client, :request, :stop],
[:tokenio_client, :request, :exception]
],
&MyApp.TokenioClientTelemetry.handle_event/4,
nil
)
defmodule MyApp.TokenioClientTelemetry do
require Logger
def handle_event([:tokenio_client, :request, :stop], %{duration: d}, %{method: m, path: p, status: s}, _) do
Logger.info("[tokenio_client] #{m} #{p} → #{s} (#{d}ms)")
:telemetry.execute([:my_app, :tokenio_client, :request], %{duration: d}, %{status: s})
end
def handle_event([:tokenio_client, :request, :exception], %{duration: d}, %{method: m, path: p}, _) do
Logger.error("[tokenio_client] #{m} #{p} failed after #{d}ms")
end
def handle_event(_, _, _, _), do: :ok
endTesting
# In your test, use a static token pointing at Bypass
setup do
bypass = Bypass.open()
{:ok, client} = TokenioClient.new(static_token: "test", base_url: "http://localhost:#{bypass.port}")
{:ok, bypass: bypass, client: client}
end
test "handles payment", %{bypass: bypass, client: client} do
Bypass.expect_once(bypass, "GET", "/v2/payments/pm:abc", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(%{
"payment" => %{"id" => "pm:abc", "status" => "INITIATION_COMPLETED",
"createdDateTime" => "2024-01-01T00:00:00Z"}
}))
end)
assert {:ok, payment} = TokenioClient.Payments.get(client, "pm:abc")
assert TokenioClient.Payments.Payment.completed?(payment)
endLicense
MIT — see LICENSE.