TruelayerClient
Production-grade Elixir client for the TrueLayer open banking platform.
Zero external crypto dependencies — ES512 request signing and HMAC-SHA256 webhook verification
use Erlang's built-in :crypto and :public_key OTP modules.
Installation
def deps do
[{:truelayer_client, "~> 1.0"}]
endQuick Start
{:ok, client} = TruelayerClient.new(
environment: :sandbox,
client_id: System.fetch_env!("TRUELAYER_CLIENT_ID"),
client_secret: System.fetch_env!("TRUELAYER_CLIENT_SECRET"),
redirect_uri: "https://yourapp.com/callback",
signing_key_pem: File.read!("keys/signing_private.pem"),
signing_key_id: System.fetch_env!("TRUELAYER_KEY_ID"),
webhook_signing_secret: System.fetch_env!("TRUELAYER_WEBHOOK_SECRET")
)API Coverage
| Module | Endpoints |
|---|---|
TruelayerClient.Auth | Auth link, exchange code, client credentials, refresh, delete credential |
TruelayerClient.Payments | Create/get/cancel; full auth flow; refunds; payment links; provider search; polling |
TruelayerClient.Payouts | Create payout, get payout |
TruelayerClient.Merchant | List/get accounts, transactions, sweeping, payment sources |
TruelayerClient.Mandates | Create, list, get; auth flow; revoke; confirm funds; constraints |
TruelayerClient.Data |
Accounts, balances, transactions (lazy Stream), cards, providers, auth links |
TruelayerClient.Verification | Account holder name verification; AHV resource |
TruelayerClient.SignupPlus | User data by payment/mandate/connected account; auth URI |
TruelayerClient.Tracking | Auth-flow event tracking |
TruelayerClient.Webhooks | HMAC-SHA256 verification, replay protection, typed dispatch |
Authentication
# Generate bank login URL
{:ok, url} = TruelayerClient.Auth.auth_link(client,
scopes: TruelayerClient.Auth.payments_scopes(),
state: csrf_token # validate on redirect!
)
# Exchange code for token in your callback handler
{:ok, token} = TruelayerClient.Auth.exchange_code(client, code, :payments)
# Server-to-server (auto-cached and refreshed)
{:ok, token} = TruelayerClient.Auth.client_credentials(
client, TruelayerClient.Auth.payments_scopes(), :payments
)Custom token store (Redis, DynamoDB…)
defmodule MyApp.RedisTokenStore do
@behaviour TruelayerClient.Auth.TokenStore
@impl true
def get(store_id, token_type) do
case Redix.command(:redix, ["GET", key(store_id, token_type)]) do
{:ok, nil} -> {:ok, nil}
{:ok, binary} -> {:ok, :erlang.binary_to_term(binary)}
{:error, _} -> {:ok, nil}
end
end
@impl true
def put(store_id, token_type, token) do
ttl = max(DateTime.diff(token.expires_at, DateTime.utc_now()), 1)
Redix.command!(:redix, ["SETEX", key(store_id, token_type), ttl,
:erlang.term_to_binary(token)])
:ok
end
@impl true
def delete(store_id, token_type) do
Redix.command(:redix, ["DEL", key(store_id, token_type)])
:ok
end
defp key(id, type), do: "truelayer:token:#{id}:#{type}"
end
{:ok, client} = TruelayerClient.new(
client_id: "...", client_secret: "...",
token_store: MyApp.RedisTokenStore
)Payments
# Create a payment
{:ok, payment} = TruelayerClient.Payments.create_payment(client, %{
amount_in_minor: 1000,
currency: "GBP",
payment_method: %{
type: "bank_transfer",
provider_selection: %{type: "user_selected"},
beneficiary: %{
type: "merchant_account",
merchant_account_id: ma_id,
reference: "Order #12345"
}
},
user: %{name: "Jane Doe", email: "jane@example.com"}
}, operation_id: "order-12345") # stable ID = safe retries
# Authorization flow
{:ok, flow} = TruelayerClient.Payments.start_authorization_flow(client, payment["id"], %{
redirect: %{return_uri: "https://yourapp.com/callback"}
})
{:ok, _} = TruelayerClient.Payments.submit_provider_selection(client, payment["id"], "ob-monzo")
{:ok, _} = TruelayerClient.Payments.submit_consent(client, payment["id"])
# Poll for final status (prefer webhooks in production)
{:ok, final} = TruelayerClient.Payments.wait_for_final_status(client, payment["id"],
timeout_ms: 60_000, interval_ms: 2_000
)Data API
# List accounts
{:ok, accounts} = TruelayerClient.Data.list_accounts(client)
# Get balance
{:ok, balance} = TruelayerClient.Data.get_account_balance(client, account_id)
# Lazy transaction stream — compose with Stream functions
client
|> TruelayerClient.Data.transaction_stream(account_id,
from: ~U[2024-01-01 00:00:00Z], to: ~U[2024-03-31 23:59:59Z])
|> Stream.filter(&(&1["transaction_type"] == "CREDIT"))
|> Stream.map(& &1["amount"])
|> Enum.sum()Webhooks
# Register typed handlers
TruelayerClient.Webhooks.on(client, TruelayerClient.Webhooks.payment_executed(), fn event ->
id = get_in(event, ["payload", "payment_id"])
MyApp.Payments.handle_executed(id)
:ok
end)
TruelayerClient.Webhooks.on(client, TruelayerClient.Webhooks.payment_failed(), fn event ->
%{"payload" => %{"payment_id" => id, "failure_reason" => reason}} = event
MyApp.Payments.handle_failed(id, reason)
:ok
end)
TruelayerClient.Webhooks.on_fallback(client, fn event ->
Logger.warning("Unhandled webhook: #{event["event_type"]}")
:ok
end)
# In your Phoenix controller (raw body required — configure CacheBodyReader plug)
def webhook(conn, _params) do
raw = conn.assigns[:raw_body]
sig = get_req_header(conn, "tl-signature") |> List.first()
ts = get_req_header(conn, "tl-timestamp") |> List.first()
case TruelayerClient.Webhooks.process(client, raw, sig, ts) do
:ok -> send_resp(conn, 200, "")
{:error, :bad_sig} -> send_resp(conn, 401, "invalid signature")
{:error, :replay} -> send_resp(conn, 401, "event too old")
{:error, reason} -> send_resp(conn, 500, inspect(reason))
end
endError Handling
case TruelayerClient.Payments.get_payment(client, payment_id) do
{:ok, payment} ->
payment
{:error, %TruelayerClient.Error{type: :not_found}} ->
nil
{:error, %TruelayerClient.Error{type: :rate_limited}} ->
# SDK already retried max_retries times
:rate_limited
{:error, %TruelayerClient.Error{trace_id: trace_id} = err} ->
Logger.error("TrueLayer error trace=#{trace_id}: #{Exception.message(err)}")
{:error, err}
endTelemetry
Attach to telemetry events for metrics and tracing:
:telemetry.attach("my-app.truelayer", [:truelayer_client, :request, :stop],
fn _name, %{duration: duration}, %{method: method, url: url, status: status}, _cfg ->
MyApp.Metrics.histogram("truelayer.request.ms",
System.convert_time_unit(duration, :native, :millisecond),
tags: ["method:#{method}", "status:#{status}"]
)
end, nil
)
Events emitted: [:truelayer_client, :request, :start | :stop | :exception]
Customise the prefix with :telemetry_prefix in TruelayerClient.new/1.
Configuration Reference
| Option | Type | Default | Description |
|---|---|---|---|
:client_id | String.t() | required | OAuth2 client ID |
:client_secret | String.t() | required | OAuth2 client secret |
:environment | :sandbox | :live | :sandbox | API environment |
:redirect_uri | String.t() | nil | Required for auth-code flows |
:signing_key_pem | binary() | nil | PEM EC key — required for Payments/Payouts/Mandates |
:signing_key_id | String.t() | nil | Key ID from TrueLayer Console |
:webhook_signing_secret | binary() | nil | HMAC-SHA256 webhook secret |
:webhook_replay_tolerance_sec | integer() | 300 | Max accepted webhook age |
:request_timeout_ms | integer() | 30_000 | HTTP request timeout |
:max_retries | integer() | 3 | Retry attempts |
:base_retry_delay_ms | integer() | 300 | Base backoff delay |
:token_store | module() | MemoryStore | TokenStore behaviour impl |
:telemetry_prefix | [atom()] | [:truelayer_client] | Telemetry prefix |
License
MIT — see LICENSE.