Production-grade Elixir client for the Column Bank API.

Column is a bank-as-a-service platform providing FDIC-insured accounts, KYC/KYB compliance, and every major US payment rail: ACH, Fedwire, SWIFT international wires, RTP/FedNow realtime payments, and checks — plus a full lending API.

Installation

def deps do
  [
    {:column, "~> 1.0"}
  ]
end

Configuration

# config/runtime.exs
config :column,
  api_key: System.fetch_env!("COLUMN_API_KEY"),
  base_url: "https://api.column.com",   # default
  timeout: 30_000,                       # ms
  recv_timeout: 60_000,                  # ms
  max_retries: 3,
  retry_delay: 500                       # ms, doubles each retry

Quick start

# Create an entity (KYC)
{:ok, person} = Column.Entities.create_person(%{
  first_name: "Ada",
  last_name: "Lovelace",
  email: "ada@example.com",
  date_of_birth: "1815-12-10",
  ssn: "123-45-6789",
  address: %{line_1: "123 Main St", city: "San Francisco", state: "CA",
             postal_code: "94102", country_code: "US"}
})

# Open a bank account
{:ok, account} = Column.BankAccounts.create(%{
  description: "Ada's wallet",
  entity_id: person["id"]
})

# Send an ACH credit
{:ok, transfer} = Column.ACH.create(%{
  bank_account_id: account["id"],
  counterparty_id: "cpty_456",
  amount: 100_00,         # in cents
  currency_code: "USD",
  type: "CREDIT",
  entry_class_code: "PPD",
  company_entry_description: "PAYROLL"
})

# Realtime transfer (RTP/FedNow) — settles in seconds
{:ok, rt} = Column.RealtimeTransfers.create(%{
  bank_account_id: account["id"],
  counterparty_id: "cpty_456",
  amount: 5_000,
  currency_code: "USD"
})

# International wire with FX quote
{:ok, quote} = Column.InternationalWires.request_fx_quote(%{
  buy_currency: "EUR", sell_currency: "USD", buy_amount: 10_000
})
{:ok, _} = Column.InternationalWires.book_fx_quote(quote["id"])
{:ok, wire} = Column.InternationalWires.create(%{
  bank_account_id: account["id"],
  fx_quote_id: quote["id"],
  beneficiary_iban: "DE89370400440532013000",
  beneficiary_name: "Acme GmbH"
})

Feature coverage

FeatureModule
KYC (persons)Column.Entities
KYB (businesses)Column.Entities
Documentary evidence & narrativesColumn.Entities
Bank accounts (FBO, sweep, clearing)Column.BankAccounts
Virtual account numbersColumn.AccountNumbers
Counterparties + IBAN validationColumn.Counterparties
ACH credits & debits (PPD/CCD/WEB/TEL/POP/IAT)Column.ACH
ACH same-day, returns, reversalsColumn.ACH
ACH positive pay rulesColumn.ACH
Book transfers (instant, internal)Column.BookTransfers
Domestic wires (Fedwire)Column.Wires
Wire drawdown requestsColumn.Wires
Wire return request flowColumn.Wires
International wires (SWIFT)Column.InternationalWires
FX quotes + rate sheetColumn.InternationalWires
SWIFT gpi trackingColumn.InternationalWires
Realtime transfers (RTP + FedNow)Column.RealtimeTransfers
Request for Payment (RFP)Column.RealtimeTransfers
Realtime return requestsColumn.RealtimeTransfers
Check issuance (print & mail)Column.Checks
Remote deposit captureColumn.Checks
Unified transfer listColumn.Transfers
Loans & loan programsColumn.Loans
Loan disbursements (with hold)Column.Disbursements
Loan payments & returnsColumn.LoanPayments
Loan sales (secondary market)Column.Loans
Events (audit log)Column.Events
Webhooks + signature verificationColumn.Webhooks
Settlement reportsColumn.Reporting
Bank account statementsColumn.Reporting
Document uploadsColumn.Documents
Sandbox simulationColumn.Simulation
Cursor pagination + streamingColumn.Pagination

Pagination

All list endpoints support cursor-based pagination. Stream all pages lazily:

Column.Pagination.stream(&Column.Transfers.list/1, limit: 100)
|> Stream.filter(fn t -> t["status"] == "SETTLED" end)
|> Enum.to_list()

# Or fetch everything at once
{:ok, all_accounts} = Column.Pagination.fetch_all(&Column.BankAccounts.list/1)

Error handling

All functions return {:ok, map()} or {:error, %Column.Error{}}:

case Column.BankAccounts.get("bacc_missing") do
  {:ok, account} ->
    account
  {:error, %Column.Error{type: :api_error, status: 404}} ->
    {:error, :not_found}
  {:error, %Column.Error{type: :network_error, message: msg}} ->
    Logger.error("Column network error: #{msg}")
    {:error, :unavailable}
  {:error, %Column.Error{type: :api_error, status: 429}} ->
    {:error, :rate_limited}
end

Error types: :api_error, :network_error, :decode_error, :validation_error.

Idempotency

All POST requests automatically generate an Idempotency-Key header. Supply your own key to control retry behaviour:

Column.ACH.create(%{...}, idempotency_key: "payment-#{order_id}")

Webhook signature verification

def handle_webhook(conn) do
  sig = get_req_header(conn, "column-signature") |> List.first()
  raw_body = conn.assigns[:raw_body]
  secret = System.fetch_env!("COLUMN_WEBHOOK_SECRET")

  case Column.Webhooks.verify_signature(raw_body, sig, secret) do
    :ok    -> process_event(conn)
    :error -> send_resp(conn, 401, "Unauthorized")
  end
end

Per-request config (multi-tenant)

Override config on any individual call:

config = %Column.Config{api_key: tenant.column_api_key}
Column.BankAccounts.list(config: config)

Retry behaviour

Transient HTTP errors (408, 429, 500, 502, 503, 504) are retried automatically with exponential backoff + random jitter. Configure via :max_retries and :retry_delay (in ms).

Sandbox testing

# Simulate receiving an inbound ACH credit
{:ok, _} = Column.Simulation.receive_ach_credit(%{
  bank_account_id: "bacc_sandbox",
  amount: 100_000,
  currency_code: "USD"
})

# Settle it
{:ok, transfers} = Column.ACH.list(bank_account_id: "bacc_sandbox")
Column.Simulation.settle_ach(hd(transfers["data"])["id"])

License

MIT