Moov
A complete, production-grade Elixir client for the Moov API -
accounts, sources, money movement, account tools, enrichment, and
authentication. Built on Req, with
automatic retries, idempotency, telemetry, and webhook signature
verification built in.
This isn't a partial wrapper around a couple of endpoints - every resource in Moov's API reference has a corresponding module:
| Moov accounts | Sources | Money movement | Account tools | Enrichment | Auth |
|---|---|---|---|---|---|
Moov.Accounts | Moov.BankAccounts | Moov.Transfers | Moov.Images | Moov.Branding | Moov.AccessTokens |
Moov.Billing | Moov.Cards | Moov.Sweeps | Moov.Products | Moov.Enrichment | Moov.E2EE |
Moov.Capabilities | Moov.ApplePay | Moov.Refunds | Moov.SupportTickets | Moov.Institutions | |
Moov.Files | Moov.GooglePay | Moov.Disputes | |||
Moov.OnboardingLinks | Moov.PaymentMethods | Moov.CardIssuing | |||
Moov.ResolutionLinks | Moov.TerminalApplications | Moov.Invoices | |||
Moov.PartnerBilling | Moov.Wallets | Moov.PaymentLinks | |||
Moov.Representatives | Moov.Receipts | ||||
Moov.Underwriting | Moov.Schedules |
Plus Moov.Client (the engine), Moov.Error, Moov.Retry,
Moov.Webhook, Moov.Telemetry, and Moov.CaseConverter underneath all
of it.
Installation
def deps do
[{:moov, "~> 1.0"}]
end
Quick start
client = Moov.Client.new(
public_key: System.fetch_env!("MOOV_PUBLIC_KEY"),
private_key: System.fetch_env!("MOOV_PRIVATE_KEY"),
api_version: "v2026.04.00"
)
{:ok, account} =
Moov.Accounts.create(client, %{
account_type: "individual",
profile: %{
individual: %{
name: %{first_name: "Ada", last_name: "Lovelace"},
email: "ada@example.com"
}
}
})
{:ok, bank_account} =
Moov.BankAccounts.link(client, account["accountID"], %{
holder_name: "Ada Lovelace",
holder_type: "individual",
routing_number: "021000021",
account_number: "123456789",
bank_account_type: "checking"
})
{:ok, transfer} =
Moov.Transfers.create(client, account["accountID"], %{
source: %{payment_method_id: source_payment_method_id},
destination: %{payment_method_id: bank_account["paymentMethodID"]},
amount: %{currency: "USD", value: 2_500}
})
Every function returns {:ok, result} or {:error, %Moov.Error{}}. Prefer
exceptions instead? Pipe into Moov.unwrap!/1:
account = Moov.Accounts.get(client, account_id) |> Moov.unwrap!()
Configuration
Build a client explicitly, or set defaults once via application
environment and call Moov.Client.new() everywhere:
# config/runtime.exs
config :moov,
public_key: System.get_env("MOOV_PUBLIC_KEY"),
private_key: System.get_env("MOOV_PRIVATE_KEY"),
api_version: "v2026.04.00"
client = Moov.Client.new()
Always set :api_version explicitly. Moov's API silently falls back to
legacy v2024.01.00 behavior if no X-Moov-Version header is sent at all -
this library never omits it, but it's worth knowing the underlying API
works that way.
Client-side / Moov.js integrations authenticate with a bearer token instead of your secret key pair:
{:ok, %{"access_token" => token}} =
Moov.AccessTokens.create(server_client, %{
grant_type: "client_credentials",
scope: "/accounts/#{account_id}/wallets.read"
})
browser_client = Moov.Client.new(access_token: token)
Design philosophy
- Responses are plain maps with their original camelCase string keys
(
account["accountID"], not a rigid struct). This keeps the library forward-compatible with new fields Moov adds over time and avoids pretending to fully model an evolving JSON API in static Elixir types. - Requests accept idiomatic snake_case keys and are camelCased
automatically (
Moov.CaseConverter), so you write%{legal_business_name: "Acme"}instead of%{"legalBusinessName" => "Acme"}. - The boundary is still fully structured. Errors (
Moov.Error), retries (Moov.Retry), idempotency, and webhook events (Moov.Webhook.Event) are real, documented,@spec'd data - exactly the places where "just a map" would bite you. - No hidden global state.
Moov.Client.new/1returns a plain, immutable struct. Build one per request, store it in aGenServer, hold several at once for different partner credentials - whatever your application needs. Nothing requires an OTP application tree.
Idempotency
Moov requires an X-Idempotency-Key header on transfer creation to safely
deduplicate retried requests. Moov.Transfers.create/4 sets this for you
automatically - a key is generated once and reused across every retry
attempt for that call, since generating a fresh key per attempt would
defeat the entire point.
Pin your own key (e.g. derived from your own order/job ID) to make retries safe across process restarts too, not just within a single call:
Moov.Transfers.create(client, account_id, params, idempotency_key: "order-#{order.id}")
Any write call can opt in the same way: idempotent: true or an explicit
idempotency_key:.
Retries
Transient failures - 429, 5xx, and network errors - are retried
automatically with exponential backoff and full jitter (Moov.Retry).
Non-transient failures (400, 401, 404, 409, 422, ...) are never
retried, since retrying a request Moov rejected as invalid will just fail
again.
# override per call
Moov.Accounts.get(client, account_id, max_retries: 0)
# or for the client's lifetime
client = Moov.Client.new(max_retries: 5)
Errors
case Moov.Accounts.get(client, account_id) do
{:ok, account} ->
account
{:error, %Moov.Error{type: :not_found}} ->
nil
{:error, %Moov.Error{type: :too_many_requests, retry_after_ms: ms}} ->
Process.sleep(ms || 1_000)
Moov.Accounts.get(client, account_id)
{:error, error} ->
Logger.error("Moov error: " <> Exception.message(error))
raise error
end
See Moov.Error for the full field list (:type, :status, :message,
:body, :request_id, :retry_after_ms, :reason).
Webhooks
def webhook_controller(conn, _params) do
{:ok, raw_body, conn} = Plug.Conn.read_body(conn)
secret = Application.fetch_env!(:my_app, :moov_webhook_secret)
case Moov.Webhook.construct_event(raw_body, conn.req_headers, secret) do
{:ok, %Moov.Webhook.Event{type: "transfer.updated", data: data}} ->
MyApp.Transfers.handle_update(data)
send_resp(conn, 200, "")
{:ok, _event} ->
send_resp(conn, 200, "")
{:error, :invalid_signature} ->
send_resp(conn, 400, "invalid signature")
end
end
Make sure no JSON parser has consumed the body before this runs - the signature is computed over the raw bytes Moov sent.
Telemetry
:telemetry.attach(
"log-moov-requests",
[:moov, :request, :stop],
fn _event, %{duration: duration}, metadata, _config ->
Logger.info(
"Moov #{metadata.method} #{metadata.path} -> " <>
"#{inspect(metadata.status || metadata.error_type)} in " <>
"#{System.convert_time_unit(duration, :native, :millisecond)}ms"
)
end,
nil
)
See Moov.Telemetry for the full event list ([:moov, :request, :start | :stop | :exception],
[:moov, :retry]).
File uploads
Moov.Files, Moov.Images, and Moov.Disputes.upload_evidence_file/5 all
accept raw binary content plus a filename/content type and handle the
multipart/form-data encoding for you:
Moov.Files.upload(client, account_id, File.read!("license.png"),
filename: "license.png",
content_type: "image/png"
)
Testing code that uses this library
This library is built on Req, so your own tests can use
Req.Test to stub Moov without any
real network I/O:
# test/support/moov_stub.ex
Req.Test.stub(MyApp.MoovStub, fn conn ->
Req.Test.json(conn, %{"accountID" => "acct_test_1"})
end)
client = Moov.Client.new(req_options: [plug: {Req.Test, MyApp.MoovStub}])
See test/support/test_support.ex and test/moov/client_test.exs in this
repository for more complete examples (idempotency, retries, error
mapping, multipart uploads).
Development
mix deps.get
mix test
mix quality # mix format --check-formatted && mix credo --strict && mix dialyzer
mix docs
License
MIT - see LICENSE.
This is an independent, community-maintained client and is not officially affiliated with or endorsed by Moov Financial, Inc.