# SaltEdgeClient
[](https://hex.pm/packages/salt_edge_client)
[](https://hexdocs.pm/salt_edge_client)
[](https://github.com/iamkanishka/salt-edge-client-elixir/actions)
[](LICENSE)
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
```elixir
# mix.exs
defp deps do
[{:salt_edge_client, "~> 1.0.0"}]
end
```
---
## Configuration
```elixir
# 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:
```elixir
SaltEdgeClient.AIS.Customers.list(timeout: 5_000, max_retries: 1)
```
---
## Quick Start
```elixir
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:
```elixir
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{}}`.
```elixir
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
```elixir
# 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
```elixir
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
end
```
### Standalone validation
```elixir
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))
end
```
---
## Request Signing
When `:private_key` is configured, every outgoing request is signed with HMAC-SHA256:
```elixir
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
```elixir
# 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
```bash
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 HTML
```
---
## Project 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 utilities
```
---
## Credo Clean
This package passes `mix credo --strict` with **zero issues**:
- All functions have `@spec` type annotations and `@doc` documentation
- No cyclomatic complexity violations (`Config.new/1` ≤ 3, `detect_event_type/1` ≤ 3 per predicate)
- All test modules use `alias` blocks — no unaliased nested module references
- `@moduledoc` present on every module
---
## License
[MIT](LICENSE)