Hex.pm Docs CI License

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 every 401 return RFC 9457 Problem Details. Sumup.Error decodes 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-unit Sumup.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.1 for Checkouts, v2.1 for Transactions reads but v1.0 for refunds, v1 for 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"}
  ]
end

Quick 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

ModuleCovers
Sumup.Checkoutscreate, get, find by reference, process (card/token/APM), deactivate, Apple Pay sessions, list payment methods
Sumup.Readerspair, list, get (with If-Modified-Since support), get live status, rename/update, delete, push a checkout, terminate
Sumup.Customerscreate, get, update
Sumup.PaymentInstrumentslist, deactivate
Sumup.Transactionsget by id/code/foreign id/client id, list (with bracketed array filters), list_all/3 lazy hypermedia-link stream, refund
Sumup.Payoutsdate-ranged report, JSON or CSV format
Sumup.Receiptsdetailed receipt lookup
Sumup.Memberscreate, get, list, list_all/3, update (full replace via PUT), delete
Sumup.Membershipslist the current authenticated user's own memberships (no merchant/user id in the path)
Sumup.Rolescreate, list, get, update, delete custom roles
Sumup.Merchantsget 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)}
end

Retries 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, "")
end

Sumup.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.