TokenioClient

Hex.pmDocumentationLicense

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"}]
end

Quick 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.Paymentsinitiate, get, list, get_with_timeout, provide_embedded_auth, generate_qr_code, poll_until_final
TokenioClient.VRPcreate_consent, get_consent, list_consents, revoke_consent, list_consent_payments, create_payment, get_payment, list_payments, confirm_funds
TokenioClient.AISlist_accounts, get_account, list_balances, get_balance, list_transactions, get_transaction, list_standing_orders, get_standing_order
TokenioClient.Bankslist_v1, list_v2, list_countries
TokenioClient.Refundsinitiate, get, list
TokenioClient.Payoutsinitiate, get, list
TokenioClient.Settlementcreate_account, list_accounts, get_account, list_transactions, get_transaction, create_rule, list_rules, delete_rule
TokenioClient.Transfersredeem, get, list
TokenioClient.Tokenslist, get, cancel
TokenioClient.TokenRequestsstore, get, get_result, initiate_bank_auth
TokenioClient.AccountOnFilecreate, get, delete
TokenioClient.SubTPPscreate, list, get, delete
TokenioClient.AuthKeyssubmit, list, get, delete
TokenioClient.Reportslist_bank_statuses, get_bank_status
TokenioClient.Webhooksset_config, get_config, delete_config, parse, typed decoders
TokenioClient.Verificationinitiate

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
end

Error 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}
end

Error 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, 504

Configuration

{: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: 2

Telemetry

# 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
end

Testing

# 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)
end

License

MIT — see LICENSE.