loyverse

Hex.pmDocsLicense

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:

Run in Livebook

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:

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.