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.2"}]
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
Every list endpoint is a lazy stream: receipts/2, items/2, variants/2,
categories/2, modifiers/2, discounts/2, taxes/2, payment_types/2,
inventory/2, stores/2, customers/2, employees/2, shifts/2,
pos_devices/2, suppliers/2, webhooks/2. merchant/1 is the one singleton,
so it returns {:ok, merchant} rather than a stream.
Single objects and anything else go through get/3:
Loyverse.get(client, "receipts/1-1234")
Loyverse.get(client, "categories/#{id}")
suppliers/ and webhooks/ 404 without their trailing slash; the named
functions send it, hand-written paths must not forget it.
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.
Safer writes
# Change some fields; the rest of the item is read and sent back untouched
Loyverse.update_item(client, item_id, %{item_name: "Latte 12oz"})
# Receive or correct stock by a delta instead of an absolute level
{:ok, %{stock_before: 12, stock_after: 9}} = Loyverse.adjust_stock(client, variant_id, store_id, -3)
Both read before they write, so neither is atomic. update_item/3 merges at
the top level only: pass the full variants list to change one variant.
Item images
Loyverse.upload_item_image(client, item_id, File.read!("latte.jpg"), "image/jpeg")
Loyverse.delete_item_image(client, item_id)
The image goes as raw bytes with image/png or image/jpeg. A multipart form
fails with 500 INTERNAL_ERROR. image_url stays the same when the image is
replaced, so bust the cache when you display it. examples/upload_item_image.exs
is a runnable script.
Receipts
REFUND receipts carry positive money. Loyverse.Receipt fixes the sign of one
receipt; summing is up to you:
receipts
|> Enum.reject(&Loyverse.Receipt.cancelled?/1)
|> Enum.map(&Loyverse.Receipt.signed/1) # or signed(receipt, "total_tax")
|> Enum.sum()
Loyverse.variant_index(client) maps every variant_id to its item name,
variant name, SKU, cost and price — the join receipts and inventory need.
OAuth
For apps serving other people's shops: the merchant approves your app instead of pasting a master token.
url = Loyverse.OAuth.authorize_url(client_id, redirect_uri, scopes: scopes, state: state)
{:ok, tokens} = Loyverse.OAuth.exchange_code(code,
client_id: client_id, client_secret: secret, redirect_uri: redirect_uri)
client = Loyverse.client(tokens.access_token)
{:ok, tokens} = Loyverse.OAuth.refresh(tokens.refresh_token,
client_id: client_id, client_secret: secret)
Storing tokens, checking state and refreshing before tokens.expires_at are
your app's job. Loyverse's docs disagree on the authorize URL and scope names,
so both endpoints are options — match what your app's Developer Dashboard shows.
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
Aggregation, deliberately — netting refunds across receipts and picking day
boundaries are business decisions, and they belong to the app making them.
Loyverse.Receipt and Loyverse.Time give you the right sign and the right
window; what you add up is yours.
Receiving webhooks. Manage subscriptions through webhooks/2, post/3 and
delete/2; the endpoint that receives them lives in your app. Loyverse only
signs deliveries (X-Loyverse-Signature) for webhooks created through OAuth.
Test
mix test
No network and no token: Req.Test stubs everything.