Current section
Files
Jump to
Current section
Files
README.md
# loyverse
[](https://hex.pm/packages/loyverse)
[](https://hexdocs.pm/loyverse)
[](LICENSE)
An Elixir client for the [Loyverse POS API](https://developer.loyverse.com/docs/).
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.
```elixir
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
```elixir
def deps do
[{:loyverse, "~> 0.2"}]
end
```
## Try it
`explore.livemd` is a [Livebook](https://livebook.dev) notebook covering every
endpoint, grouped by resource:
[](https://livebook.dev/run?url=https%3A%2F%2Fgithub.com%2FAAlvAAro%2Floyverse_ex%2Fblob%2Fmain%2Fexplore.livemd)
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:
```elixir
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`:
```elixir
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]}`.
```elixir
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
```elixir
# 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
```elixir
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:
```elixir
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.
```elixir
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.
```elixir
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 empty `receipts` array
whenever the parameter is present, whatever its value. Omitted, receipts come
back newest-first anyway. `receipts/2` does 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!/3` stops on both.
- **Rows live under a per-resource key** — `receipts`, `items`,
`inventory_levels`. `stream!/3` takes the first list-valued key rather than
keeping a lookup table in sync with the API.
- **`REFUND` receipts carry positive `total_money`.** Summing blindly overstates
revenue by twice every refund. Cancelled receipts also come back, with
`cancelled_at` set. This library hands you the raw rows — how you net them is
yours.
- **`/inventory` is unreadable alone**: `variant_id` and a number, nothing
human-readable. Join against `items/2`, whose `variants` carry `variant_id`
and `sku`.
- **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
```sh
mix test
```
No network and no token: `Req.Test` stubs everything.