railsr
Production-grade Elixir client for the Railsr Embedded Finance API.
Complete coverage of every endpoint group: Endusers (v2), Ledgers, Transactions, Beneficiaries, Cards, Direct Debit / Mandates, Compliance Firewall, Webhooks, and Customer management.
Features
| Feature | Detail |
|---|---|
| Full API coverage | All Railsr v1/v2 endpoints across 10 resource modules |
| OAuth 2.0 token management | ETS-backed cache with automatic pre-expiry refresh |
| Retry with full-jitter backoff | Configurable max retries, base backoff; respects 429 |
| Circuit breaker | Three-state (closed/open/half-open) protects against cascade failures |
| Client-side rate limiter | Token-bucket, ETS-backed, lock-free reads |
| Idempotency keys | Auto-generated on every POST/PUT/PATCH — override per-call |
| Telemetry | [:railsr, :request, :start|:stop|:exception] — Telemetry.Metrics compatible |
| Typed returns | {:ok, struct} or {:error, %Railsr.Error{}} everywhere |
| Webhook verification | HMAC-SHA256 signature verification with constant-time compare |
| Config validation | NimbleOptions validates all config keys at startup |
| PLAY → LIVE |
Only the :environment config key changes |
| Zero business logic | Pure transport layer — no assumptions about your domain |
Installation
def deps do
[
{:railsr, "~> 1.0"}
]
endConfiguration
# config/config.exs (or runtime.exs for secrets)
config :railsr,
client_id: System.get_env("RAILSR_CLIENT_ID"),
client_secret: System.get_env("RAILSR_CLIENT_SECRET"),
environment: :play, # :play | :play_live | :live
timeout: 30_000, # HTTP timeout in ms (default: 30_000)
max_retries: 3, # Retries on 429 / 5xx (default: 3)
base_backoff_ms: 200, # Backoff base for retry jitter (default: 200)
rate_limit_rps: 50, # Client-side RPS cap (default: 50)
pool_size: 10, # HTTP connection pool size (default: 10)
telemetry_prefix: [:railsr] # Telemetry event prefix (default: [:railsr])Never hard-code credentials. Use
System.get_env/1or a secrets manager and load viaconfig/runtime.exs.
Quick Start
1. Onboard an enduser (person)
alias Railsr.Resources.{Endusers, Ledgers, Transactions, Beneficiaries}
{:ok, enduser} = Endusers.create(%{
person: %{
name: %{family_name: "Smith", given_name: "Alice"},
email: "alice@example.com",
date_of_birth: "1990-01-15",
nationality: "GB",
country_of_residence: ["GB"],
address: %{
address_number: "14",
address_street: "High Street",
address_city: "London",
address_postal_code: "EC1A 1BB",
address_iso_country: "GB"
}
}
})
# => {:ok, %Railsr.Types.Enduser{enduser_id: "eu_xxx", status: "pending"}}2. Trigger KYC
{:ok, check} = Endusers.create_kyc_check(enduser.enduser_id)
# Listen for enduser-kyc-passed / enduser-kyc-failed webhooks
# Or poll:
{:ok, _} = Endusers.wait_for_status(enduser.enduser_id, ["active"], timeout_ms: 120_000)3. Create a GBP ledger
{:ok, ledger} = Ledgers.create(%{
holder_id: enduser.enduser_id,
holder_type: "enduser",
ledger_type: "standard-gbp",
asset_class: "currency",
asset_type: "gbp"
})
# => {:ok, %Railsr.Types.Ledger{uk_sort_code: "040004", uk_account_number: "..."}}4. Create a beneficiary and send money
{:ok, ben} = Beneficiaries.create(%{
name: "Bob Jones",
uk_account_number: "87654321",
uk_sort_code: "204514",
currency: "GBP",
enduser_id: enduser.enduser_id
})
# Run Confirmation of Payee (UK FPS recommended)
{:ok, ben} = Beneficiaries.verify(ben.beneficiary_id, "faster-payment")
IO.inspect(ben.cop_result) # "matched" | "close_match" | "no_match"
# Send money
{:ok, tx} = Transactions.send_money(%{
ledger_id: ledger.ledger_id,
beneficiary_id: ben.beneficiary_id,
amount: 1000, # pence — £10.00
currency: "GBP",
payment_type: "faster-payment",
reason: "Invoice #42"
})
# Poll for terminal status
{:ok, tx} = Transactions.wait_for_terminal(tx.transaction_id)Cards
alias Railsr.Resources.Cards
# Issue a virtual card
{:ok, card} = Cards.create(%{
ledger_id: ledger.ledger_id,
card_type: "virtual",
card_programme_id: "cp_xxx"
})
# Freeze / unfreeze
{:ok, _} = Cards.freeze(card.card_id)
{:ok, _} = Cards.unfreeze(card.card_id)
# Add a daily spend limit of £100
{:ok, rule} = Cards.create_rule(card.card_id, %{
rule_type: "amount_limit",
limit_amount: 10_000,
limit_currency: "GBP",
limit_interval: "daily"
})
# Block gambling MCCs
{:ok, _} = Cards.create_rule(card.card_id, %{
rule_type: "mcc_block",
mcc_list: ["7995", "7801", "7802"]
})
# Replace lost card
{:ok, new_card} = Cards.replace(card.card_id, %{replacement_reason: "lost"})Direct Debit
alias Railsr.Resources.{Mandates, Payments}
# Create mandate (collect from enduser's external bank)
{:ok, mandate} = Mandates.create(%{
enduser_id: enduser.enduser_id,
ledger_id: ledger.ledger_id,
account_number: "12345678",
sort_code: "040004",
account_holder_name: "Alice Smith",
reference: "MYAPP-001"
})
# Wait for BACS activation (3-5 working days — use webhook in production)
{:ok, mandate} = Mandates.wait_for_active(mandate.mandate_id)
# Collect funds
{:ok, payment} = Payments.create(%{
mandate_id: mandate.mandate_id,
amount: 5_000, # £50.00
reason: "Wallet top-up"
})Compliance Firewall
alias Railsr.Resources.{Firewall, Transactions}
# Set rules
{:ok, _} = Firewall.set_rules(%{
rules: [
%{
name: "Quarantine large international payments",
rule: """
(and
(> (transaction.amount) 500000)
(not (= (beneficiary.country) "GB")))
""",
action: "quarantine",
priority: 10
}
]
})
# Upload a blocked BIC dataset
{:ok, _} = Firewall.create_dataset(%{
name: "blocked_bics",
columns: ["bic"],
rows: [["CHASUS33"], ["DEUTDEDB"]]
})
# Resolve quarantined transactions
{:ok, quarantined} = Transactions.list_quarantined()
for tx <- quarantined do
# Your compliance review logic here
if approved?(tx) do
Transactions.approve(tx.transaction_id)
else
Transactions.reject(tx.transaction_id, "Policy violation: high-risk jurisdiction")
end
endWebhooks
Configure endpoint
Railsr.Resources.Webhooks.configure(%{
url: "https://myapp.com/webhooks/railsr",
secret: System.get_env("RAILSR_WEBHOOK_SECRET")
})Verify incoming payloads (Phoenix example)
defmodule MyAppWeb.WebhookController do
use MyAppWeb, :controller
alias Railsr.Resources.Webhooks
def railsr(conn, _params) do
raw_body = conn.assigns[:raw_body] # captured before JSON parsing
signature = get_req_header(conn, "x-railsr-signature") |> List.first()
secret = Application.get_env(:myapp, :railsr_webhook_secret)
case Webhooks.verify_signature(raw_body, signature, secret) do
:ok ->
{:ok, event} = Webhooks.parse_event(Jason.decode!(raw_body))
handle_event(event)
send_resp(conn, 200, "ok")
{:error, :invalid_signature} ->
send_resp(conn, 401, "invalid signature")
end
end
defp handle_event(%{type: "transaction-quarantined"} = event) do
# Trigger your compliance review workflow
MyApp.Compliance.review(event.data)
end
defp handle_event(%{type: "enduser-kyc-passed"} = event) do
MyApp.Onboarding.kyc_passed(event.data["enduser_id"])
end
defp handle_event(_event), do: :ok
endTelemetry
# Attach a development logger
Railsr.Telemetry.attach_logger(:debug)
# Or define proper metrics in your Telemetry supervisor:
import Telemetry.Metrics
def metrics do
[
counter("railsr.request.stop.count",
tags: [:method, :path]
),
summary("railsr.request.stop.duration",
unit: {:native, :millisecond},
tags: [:method, :path]
),
counter("railsr.request.stop.error_count",
keep: &match?(%{status: s} when s >= 400, &1),
tags: [:method, :path]
)
]
endError Handling
All public functions return {:ok, result} or {:error, %Railsr.Error{}}.
case Railsr.Resources.Ledgers.get("led_missing") do
{:ok, ledger} ->
IO.inspect(ledger)
{:error, %Railsr.Error{type: :not_found}} ->
Logger.warning("Ledger not found")
{:error, %Railsr.Error{type: :rate_limited, retryable?: true}} ->
# The SDK retries automatically, but you can handle residual rate limits here
Process.sleep(1_000)
retry()
{:error, %Railsr.Error{type: :unauthorized}} ->
# Token was revoked — SDK invalidates cache and retries once automatically
Logger.error("Auth failure — check RAILSR_CLIENT_ID / RAILSR_CLIENT_SECRET")
{:error, %Railsr.Error{type: t, message: msg, request_id: rid}} ->
Logger.error("Railsr error type=#{t} message=#{msg} request_id=#{rid}")
endError Types
| Type | HTTP | Retryable? |
|---|---|---|
:unauthorized | 401 | No (token cache auto-refreshed once) |
:forbidden | 403 | No |
:not_found | 404 | No |
:conflict | 409 | No |
:unprocessable | 422 | No |
:rate_limited | 429 | Yes |
:server_error | 5xx | Yes |
:circuit_open | — | Yes |
:timeout | — | Yes |
:network | — | Yes |
Environment Reference
| Config | URL |
|---|---|
:play | https://play.railsbank.com |
:play_live | https://playlive.railsbank.com |
:live | https://live.railsbank.com |
To credit a ledger with test funds in PLAY:
Railsr.Resources.Ledgers.dev_credit("led_xxx", 100_000, "GBP")
# Adds £1,000 of fake balance — PLAY environment onlyTesting
mix test
mix test --cover
mix coveralls.html # opens HTML coverage report
mix credo --strict
mix dialyzer
mix ci # runs all checksContributing
- Fork the repo
-
Create a feature branch:
git checkout -b feat/my-feature - Write tests first
-
Run
mix ci— all checks must pass -
Open a PR against
main
License
MIT — see LICENSE.