SaltEdgeClient
Production-grade Elixir hex package for the SaltEdge API v6, covering all three product areas:
- AIS — Account Information Service
- PIS — Payment Initiation Service
- Data Enrichment Platform — Categorisation, Merchant ID & Financial Insights
Installation
# mix.exs
defp deps do
[{:salt_edge_client, "~> 1.0.0"}]
endConfiguration
# config/config.exs
config :salt_edge_client,
app_id: System.get_env("SALTEDGE_APP_ID"),
secret: System.get_env("SALTEDGE_SECRET"),
private_key: System.get_env("SALTEDGE_PRIVATE_KEY"), # optional: request signing
webhook_secret: System.get_env("SALTEDGE_WEBHOOK_SECRET"), # optional: webhook validation
timeout: 30_000, # ms
max_retries: 3,
retry_base_delay: 200, # ms
retry_max_delay: 30_000, # ms
debug: false
All options can be overridden per-call via the opts keyword list:
SaltEdgeClient.AIS.Customers.list(timeout: 5_000, max_retries: 1)Quick Start
alias SaltEdgeClient.AIS.{Customers, Connections, Accounts, Transactions}
alias SaltEdgeClient.Paginator
# AIS — Create a customer and open a connect session
{:ok, customer} = Customers.create(identifier: "alice@example.com")
{:ok, session} = Connections.connect(
customer_id: customer["id"],
consent: %{scopes: ["accounts", "transactions"]},
attempt: %{return_to: "https://yourapp.com/callback"}
)
IO.puts session["connect_url"]
# => "https://www.saltedge.com/connect?token=..."
# Stream all accounts lazily
Accounts.stream(connection_id: "conn-123")
|> Stream.filter(fn a -> a["currency_code"] == "EUR" end)
|> Enum.each(fn a -> IO.puts("#{a["name"]}: #{a["balance"]}") end)
# Collect all transactions across all pages
{:ok, txns} = Paginator.collect(
&Transactions.list/1,
connection_id: "conn-123",
account_id: "acc-456"
)
IO.puts("Total transactions: #{length(txns)}")API Coverage
AIS (Account Information Service)
| Module | Operations |
|---|---|
SaltEdgeClient.AIS.Countries | list/1 |
SaltEdgeClient.AIS.Providers | list/1, show/2, stream/1, all/1 |
SaltEdgeClient.AIS.Customers | create/1, show/2, list/1, remove/2, stream/1, all/1 |
SaltEdgeClient.AIS.Connections | connect/1, reconnect/2, refresh/2, background_refresh/2, update/2, show/2, list/1, remove/2, stream/1 |
SaltEdgeClient.AIS.Consents | list/1, show/2, revoke/2, stream/1 |
SaltEdgeClient.AIS.Accounts | list/1, stream/1, all/1 |
SaltEdgeClient.AIS.Transactions | list/1, update/2, stream/1, all/1 |
SaltEdgeClient.AIS.Rates | list/1, stream/1 |
PIS (Payment Initiation Service)
| Module | Operations |
|---|---|
SaltEdgeClient.PIS.Customers | create/1, show/2, list/1, remove/2, stream/1 |
SaltEdgeClient.PIS.Providers | list/1, show/2, stream/1 |
SaltEdgeClient.PIS.Payments | create/1, show/2, list/1, refresh/2, stream/1 |
SaltEdgeClient.PIS.PaymentTemplates | show/2, list/1, stream/1 |
SaltEdgeClient.PIS.BulkPayments | create/1, show/2, list/1, refresh/2, stream/1 |
Data Enrichment Platform
| Module | Operations |
|---|---|
SaltEdgeClient.Enrichment.Buckets | create/1, show/2, remove/2 |
SaltEdgeClient.Enrichment.Accounts | import/1, list/1, remove/2, stream/1 |
SaltEdgeClient.Enrichment.Transactions | import/1, categorize/1, categorized/1, categorized_stream/1 |
SaltEdgeClient.Enrichment.Merchants | show/1 |
SaltEdgeClient.Enrichment.Categories | list/1, list_by_type/2, learn/1 |
SaltEdgeClient.Enrichment.CustomerRules | list/1, show/2, remove/2, stream/1 |
SaltEdgeClient.Enrichment.FinancialInsights | create/1, show/2, list/1, remove/2, stream/1 |
Pagination
All list endpoints return {:ok, %{data: [...], next_id: "..."}}.
Three pagination patterns are available:
alias SaltEdgeClient.{AIS.Transactions, Paginator}
# Option A: collect all into a list (eager)
{:ok, all_txns} = Paginator.collect(
&Transactions.list/1,
connection_id: "conn-123"
)
# Option B: lazy Stream (Enum / Stream compatible)
Paginator.stream(&Transactions.list/1, connection_id: "conn-123")
|> Stream.filter(fn t -> t["amount"] < 0 end)
|> Enum.take(10)
# Option C: page-by-page callback
Paginator.each_page(
&Transactions.list/1,
fn page -> IO.inspect(length(page), label: "page size") end,
connection_id: "conn-123"
)
# Convenience wrappers on service modules
SaltEdgeClient.AIS.Accounts.stream(connection_id: "conn-123") |> Enum.to_list()
SaltEdgeClient.AIS.Accounts.all(connection_id: "conn-123") # {:ok, [...]}Error Handling
Every function returns {:ok, result} or {:error, %SaltEdgeClient.Error{}}.
alias SaltEdgeClient.Error
case SaltEdgeClient.AIS.Customers.show("missing-id") do
{:ok, customer} ->
customer
{:error, %Error{status: 404, class: "CustomerNotFound"}} ->
:not_found
{:error, %Error{} = err} when Error.server_error?(err) ->
:retry_later
{:error, %Error{status: :network}} ->
:connectivity_problem
end
# Helper predicates
Error.not_found?(err) # status == 404
Error.rate_limited?(err) # status == 429
Error.server_error?(err) # status >= 500
Error.retryable?(err) # network | 429 | 5xx
Error.has_class?(err, "CustomerNotFound")Webhooks
Phoenix / Plug Router
# router.ex
forward "/webhooks/saltedge", SaltEdgeClient.Webhook.Handler,
validate_signature: true,
handlers: %{
ais_success: &MyApp.Webhooks.handle_ais_success/1,
ais_failure: &MyApp.Webhooks.handle_ais_failure/1,
ais_notify: &MyApp.Webhooks.handle_ais_notify/1,
ais_destroy: &MyApp.Webhooks.handle_ais_destroy/1,
pis_payment_success: &MyApp.Webhooks.handle_payment_success/1,
pis_payment_failure: &MyApp.Webhooks.handle_payment_failure/1
}Handler implementation
defmodule MyApp.Webhooks do
def handle_ais_success(%{"connection_id" => conn_id, "stage" => "finish"}) do
MyApp.BankSync.run(conn_id)
end
def handle_ais_success(_data), do: :ok
def handle_ais_failure(%{"error_class" => "InvalidCredentials"} = data) do
MyApp.Notifications.notify_user(data["customer_id"], :reconnect_needed)
end
def handle_ais_failure(_data), do: :ok
endStandalone validation
alias SaltEdgeClient.Webhook.Validator
body = conn.assigns[:raw_body]
sig = get_req_header(conn, "signature") |> List.first()
case Validator.validate(body, sig) do
:ok -> process(conn)
{:error, reason} -> send_resp(conn, 401, to_string(reason))
endRequest Signing
When :private_key is configured, every outgoing request is signed with HMAC-SHA256:
config :salt_edge_client, private_key: System.get_env("SALTEDGE_PRIVATE_KEY")
The Expires-at and Signature headers are added automatically by SaltEdgeClient.Client.
Telemetry
# Events emitted per request:
[:salt_edge_client, :request, :start] # %{system_time: ...}, %{method:, path:}
[:salt_edge_client, :request, :stop] # %{system_time: ...}, %{method:, path:, result:}
# Attach the built-in Logger handler in Application.start/2:
SaltEdgeClient.Telemetry.attach_default_logger()
# Or attach your own:
:telemetry.attach("my-handler", [:salt_edge_client, :request, :stop],
fn _event, _measurements, meta, _config ->
Logger.info("SaltEdge #{meta.method} #{meta.path} -> #{meta.result}")
end, nil)Running Tests
mix deps.get
mix test # all tests
mix test --cover # with ExCoveralls HTML report
mix credo --strict # style analysis (0 issues expected)
mix dialyzer # type checking
mix docs # generate ExDoc HTMLProject Structure
lib/
├── salt_edge_client.ex # Entry point + module index
└── salt_edge_client/
├── application.ex # OTP Application
├── client.ex # HTTP executor (Req + retries + telemetry)
├── config.ex # Runtime configuration
├── error.ex # %Error{} struct + predicates
├── paginator.ex # stream/2, collect/2, each_page/3
├── signer.ex # HMAC-SHA256 signing + webhook verify
├── telemetry.ex # Telemetry events + default logger
├── ais/ # Account Information Service
│ ├── countries.ex
│ ├── providers.ex
│ ├── customers.ex
│ ├── connections.ex
│ ├── consents.ex
│ ├── accounts.ex
│ ├── transactions.ex
│ └── rates.ex
├── pis/ # Payment Initiation Service
│ ├── customers.ex
│ ├── providers.ex
│ ├── payments.ex
│ ├── payment_templates.ex
│ └── bulk_payments.ex
├── enrichment/ # Data Enrichment Platform
│ ├── buckets.ex
│ ├── accounts.ex
│ ├── transactions.ex
│ ├── merchants.ex
│ ├── categories.ex
│ ├── customer_rules.ex
│ └── financial_insights.ex
└── webhook/
├── handler.ex # Plug-based handler
└── validator.ex # Signature validation
test/
├── salt_edge_client/
│ ├── ais/ # AIS service tests
│ ├── pis/ # PIS service tests
│ ├── enrichment/ # Enrichment tests
│ ├── webhook/ # Webhook tests
│ ├── error_test.exs
│ ├── signer_test.exs
│ ├── paginator_test.exs
│ └── config_test.exs
└── support/bypass_helpers.ex # Shared Bypass test utilitiesCredo Clean
This package passes mix credo --strict with zero issues:
-
All functions have
@spectype annotations and@docdocumentation -
No cyclomatic complexity violations (
Config.new/1≤ 3,detect_event_type/1≤ 3 per predicate) -
All test modules use
aliasblocks — no unaliased nested module references @moduledocpresent on every module