A production-grade, fully-typed Elixir client for the SumUp API — Checkouts, Readers, Customers, Payment Instruments, Transactions, Payouts, Receipts, Members, Memberships, Roles, and Merchants.
Why this exists
SumUp publishes official SDKs for Node, Go, Python, Java, .NET, PHP and Rust, but not Elixir. This package fills that gap, and does so by modeling the API's real shape rather than papering over it:
- Two error formats, normalized into one. Legacy resources
(Checkouts, Customers, Transactions, Payouts) return
{"error_code", "message", "param"}(sometimes as a list); newer resources (Readers, Members, Merchants) and every401return RFC 9457 Problem Details.Sumup.Errordecodes both into one struct so your code never has to care which generation of the API it's talking to. - Two money formats, kept distinct. Legacy endpoints represent money
as a float major-unit amount (
Sumup.Money.Float); Readers use an integer minor-unitSumup.Money.MinorUnit. They're deliberately not unified into one type — doing so would either lose precision or require guessing a currency's decimal exponent. - Per-resource API versioning. Each resource module hits whatever
version SumUp actually ships for it (
v0.1for Checkouts,v2.1for Transactions reads butv1.0for refunds,v1for Merchants, etc.) rather than assuming one version for the whole client. - Reader checkouts modeled as genuinely async. Pushing a payment to a physical Solo reader returns as soon as it's been sent to the device; this library returns that intermediate state honestly instead of faking synchronicity with an internal poll loop.
Installation
def deps do
[
{:sumup, "~> 1.0"}
]
endQuick start
config = Sumup.Config.new!(api_key: {:system, "SUMUP_API_KEY"})
{:ok, checkout} =
Sumup.Checkouts.create(config,
checkout_reference: "order-1234",
amount: 10.50,
currency: "EUR",
merchant_code: "MC12345"
)
{:ok, paid} =
Sumup.Checkouts.process(config, checkout.id,
payment_type: "card",
card: %{number: "4111111111111111", expiry_month: 12, expiry_year: 2030, cvv: "123", name: "Jane Doe"}
)Configuration
Sumup.Config.new!/1 validates its options via NimbleOptions — see
Sumup.Config's module docs for the full option list (timeouts, retry
policy, telemetry prefix, custom Req options, etc). Auth accepts a raw
string or a lazily-resolved {:system, "ENV_VAR"} / {module, function, args} tuple, so secrets never need to be resolved before your
supervision tree starts:
Sumup.Config.new!(
api_key: {:system, "SUMUP_API_KEY"},
max_retries: 5,
receive_timeout: 15_000
)Resources
| Module | Covers |
|---|---|
Sumup.Checkouts | create, get, find by reference, process (card/token/APM), deactivate, Apple Pay sessions, list payment methods |
Sumup.Readers | pair, list, get (with If-Modified-Since support), get live status, rename/update, delete, push a checkout, terminate |
Sumup.Customers | create, get, update |
Sumup.PaymentInstruments | list, deactivate |
Sumup.Transactions | get by id/code/foreign id/client id, list (with bracketed array filters), list_all/3 lazy hypermedia-link stream, refund |
Sumup.Payouts | date-ranged report, JSON or CSV format |
Sumup.Receipts | detailed receipt lookup |
Sumup.Members | create, get, list, list_all/3, update (full replace via PUT), delete |
Sumup.Memberships | list the current authenticated user's own memberships (no merchant/user id in the path) |
Sumup.Roles | create, list, get, update, delete custom roles |
Sumup.Merchants | get profile (version + change-status pattern), list/get persons — read-only, SumUp's public API has no merchant-update endpoint |
Sumup.Webhooks isn't a REST resource — see Webhooks below.
Error handling
Every function returns {:ok, result} or {:error, %Sumup.Error{}}.
Sumup.Error exposes a consistent set of fields (:status, :code,
:message, :param, :type, :title, :instance, :errors) no matter
which underlying shape the API returned, plus the original :raw body if
you need it:
case Sumup.Checkouts.process(config, checkout_id, payment_type: "card", card: card) do
{:ok, checkout} -> checkout
{:error, %Sumup.Error{code: "CARD_DECLINED"} = error} -> {:declined, Sumup.Error.message(error)}
{:error, error} -> {:failed, Sumup.Error.message(error)}
endRetries and telemetry
429 and 5xx responses (and transport failures) are retried with
exponential backoff and jitter, honoring a numeric retry-after header
when SumUp sends one. Every request emits :telemetry spans — see
Sumup.Telemetry for the full event list — so you can hook up logging,
metrics, or tracing without modifying this library:
:telemetry.attach(
"sumup-logger",
[:sumup, :request, :stop],
fn _event, measurements, metadata, _config ->
Logger.info("sumup #{metadata.resource}.#{metadata.operation} -> #{metadata.status} (#{measurements.duration}ns)")
end,
nil
)Webhooks
SumUp's Checkout webhooks are not signed — there's no HMAC or
signature header to check for this API (that's a different SumUp product
entirely; see Sumup.Webhooks's moduledoc for the full explanation).
SumUp's own recommendation is to treat the webhook body as an
unauthenticated pointer and re-fetch the real state from the API:
def handle_webhook(conn, _params) do
config = my_sumup_config()
with {:ok, raw_body, conn} <- Plug.Conn.read_body(conn),
{:ok, checkout} <- Sumup.Webhooks.handle(raw_body, config) do
MyApp.handle_checkout_update(checkout)
end
# Always ack quickly with 2xx, however processing went — SumUp retries
# non-2xx deliveries at 1 min, 5 min, 20 min, and 2 hours.
Plug.Conn.send_resp(conn, 200, "")
endSumup.Webhooks.parse/1 decodes the payload without trusting it;
Sumup.Webhooks.verify/2 does the recommended re-fetch;
Sumup.Webhooks.handle/2 does both in one call, as above.
Streaming large result sets
Sumup.Transactions.list_all/3 and Sumup.Members.list_all/3 return a
lazy Stream built on Sumup.Pagination, so you can Stream.take/2,
Stream.filter/2, etc. without fetching more pages than you need:
Sumup.Transactions.list_all(config, merchant_code, statuses: ["SUCCESSFUL"])
|> Stream.filter(&(&1.amount.amount > 100.0))
|> Stream.take(50)
|> Enum.to_list()Testing your own integration
This library's own test suite uses Req.Test (shipped inside :req) to
stub HTTP without a real server — no Bypass/Plug.Cowboy needed. You
can do the same in your app: build a Sumup.Config with req_options: [plug: {Req.Test, MyStubName}] and drive Req.Test.stub/2 /
Req.Test.expect/3 from your tests.
Development
mix deps.get
mix test
mix credo --strict
mix dialyzer
mix format --check-formatted
A note on endpoint coverage
Every path, parameter, and response field in this library was
cross-checked against SumUp's published
OpenAPI spec — including some
easy-to-miss details it corrects for: Sumup.Memberships has no
merchant/user id in its path (it's always the calling user's own
memberships), Sumup.Transactions.refund/4 is scoped under
/merchants/{merchant_code}/payments/{id}/refunds and returns no body,
Sumup.Merchants has no update endpoint in the public API, reader
checkouts use tip_rates/tip_timeout rather than a flat tip_amount,
and transaction history's array filters (statuses[], payment_types[],
entry_modes[], types[]) are sent as repeated bracketed query keys, not
comma-joined values. SumUp does still evolve its API surface over time,
so if you hit a mismatch, please open an issue or PR — every resource
module follows the same pattern, so adding or correcting an endpoint is
usually a small, self-contained change.
License
MIT. See LICENSE.