loyverse

Hex.pm Docs License

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:

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

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:

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.