Moov

Hex.pmDocs

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 accountsSourcesMoney movementAccount toolsEnrichmentAuth
Moov.AccountsMoov.BankAccountsMoov.TransfersMoov.ImagesMoov.BrandingMoov.AccessTokens
Moov.BillingMoov.CardsMoov.SweepsMoov.ProductsMoov.EnrichmentMoov.E2EE
Moov.CapabilitiesMoov.ApplePayMoov.RefundsMoov.SupportTicketsMoov.Institutions
Moov.FilesMoov.GooglePayMoov.Disputes
Moov.OnboardingLinksMoov.PaymentMethodsMoov.CardIssuing
Moov.ResolutionLinksMoov.TerminalApplicationsMoov.Invoices
Moov.PartnerBillingMoov.WalletsMoov.PaymentLinks
Moov.RepresentativesMoov.Receipts
Moov.UnderwritingMoov.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

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.