loyverse
An Elixir client for the Loyverse POS API.
Covers what the API is actually awkward about: cursor pagination, local business days against a UTC-only API, and the handful of behaviours the docs don't mention.
client = Loyverse.client(System.fetch_env!("LOYVERSE_TOKEN"))
{from, to} = Loyverse.Time.utc_window(~D[2026-07-01], ~D[2026-07-31], -6)
client
|> Loyverse.receipts(
created_at_min: DateTime.to_iso8601(from),
created_at_max: DateTime.to_iso8601(to)
)
|> Enum.to_list()
Install
def deps do
[{:loyverse, "~> 0.1"}]
end
Try it
explore.livemd is a Livebook notebook covering every
endpoint, grouped by resource:
It needs a Livebook secret named LOYVERSE_TOKEN. Point it at a test account —
the notebook creates, updates and deletes real objects.
Design
Credentials are an argument, never global state.Loyverse.client/2 takes a
token, so one OS process can serve as many Loyverse accounts as it likes. That
is what a multi-tenant app needs, and retrofitting it later is painful.
List endpoints return lazy streams.Enum.take(stream, 10) costs one
request no matter how much history exists; Enum.to_list/1 walks every page.
Errors are matchable.get/3 returns {:ok, body} | {:error, %Loyverse.Error{}},
so a rate limit is distinguishable from a bad token:
case Loyverse.get(client, "receipts") do
{:ok, body} -> body
{:error, %Loyverse.Error{status: 429}} -> back_off()
{:error, error} -> Logger.error(Exception.message(error))
end
get!/3 and stream!/3 raise instead — a stream has nowhere sensible to put an
error tuple.
Req retries 429 and 5xx with exponential backoff by default, so a
%Loyverse.Error{status: 429} means it retried and still failed.
Resources
receipts/2, items/2, categories/2, inventory/2, stores/2,
customers/2 — all lazy streams. Anything else the API exposes works through
stream!/3 and get/3 directly:
Loyverse.stream!(client, "discounts")
Loyverse.get(client, "receipts/1-1234")
Adding a named function for another resource is one line.
Writes
post/3 is an upsert on every resource — include the object's id and it
updates, omit it and it creates. There is no PUT. delete/2 soft-deletes and
returns %{"deleted_object_ids" => [id]}.
Loyverse.post!(client, "items", %{item_name: "T-shirt", track_stock: true})
Loyverse.post!(client, "inventory", %{
inventory_levels: [%{variant_id: v, store_id: s, stock_after: 40}]
})
Loyverse.delete(client, "items/#{item_id}")
stock_after sets the level rather than adjusting it, and stock only exists on
items with track_stock: true — setting that back to false zeroes every level
for the item at every store.
Local business days
Loyverse timestamps are UTC. A naive midnight-to-midnight UTC window does not line up with a local calendar day — at UTC-6 an 8pm sale is already tomorrow in UTC, and reporting it on the wrong day is the easiest way to get a daily sales figure quietly wrong.
Loyverse.Time.utc_window(~D[2026-03-01], ~D[2026-03-01], -6)
#=> {~U[2026-03-01 06:00:00Z], ~U[2026-03-02 05:59:59.999Z]}
Loyverse.Time.local_date("2026-03-02T02:00:00.000Z", -6)
#=> ~D[2026-03-01]
The offset is a number of hours, not a named timezone: correct for a business in
one fixed-offset place, and it keeps this library free of a timezone database.
Somewhere with DST needs a real zone — convert with tz and pass the resulting
UTC datetimes yourself.
API behaviours worth knowing
Learned from a working integration, not from the docs:
- Never pass
order. Loyverse silently returns an emptyreceiptsarray whenever the parameter is present, whatever its value. Omitted, receipts come back newest-first anyway.receipts/2does not send it. - A cursor can terminate as
"", not only by being absent, and a page can come back empty with a cursor still set.stream!/3stops on both. - Rows live under a per-resource key —
receipts,items,inventory_levels.stream!/3takes the first list-valued key rather than keeping a lookup table in sync with the API. REFUNDreceipts carry positivetotal_money. Summing blindly overstates revenue by twice every refund. Cancelled receipts also come back, withcancelled_atset. This library hands you the raw rows — how you net them is yours./inventoryis unreadable alone:variant_idand a number, nothing human-readable. Join againstitems/2, whosevariantscarryvariant_idandsku.- Rate limit is roughly 60 requests/min, per account.
Not included
Authentication beyond a personal access token. Loyverse also supports OAuth 2.0
(cloud.loyverse.com/oauth/authorize, scopes RECEIPTS/ITEMS/MERCHANT/STORES)
which is what an app serving other people's shops needs — customers approve
access rather than pasting a master token. Because credentials are already a
per-client argument, adding it is additive: a new client/2 source, nothing
else changes.
Aggregation is out of scope, deliberately — netting refunds and picking day
boundaries are business decisions, and they belong to the app making them.
So are webhook subscriptions beyond the raw webhooks/ endpoint, and the
multipart image upload on items/{id}/image.
Test
mix test
No network and no token: Req.Test stubs everything.