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.1"}]
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
`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:
```elixir
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]}`.
```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.
## 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
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
```sh
mix test
```
No network and no token: `Req.Test` stubs everything.