For developers

API reference

Every operation with the permission it needs, how money and quantities are expressed, and one worked example from order to return.

/v1 · contract version 1.0.0 · OpenAPI reference (openapi.json)


The surface

There are 13 endpoints. The permission column is the key the token must hold and, because of the rule above, the key its creator must also still hold.

EndpointPermission requiredWhat it does
GET /v1/productsproducts:readThe catalogue, paged. Discontinued items are returned with active: false, never omitted.
GET /v1/products/{id}products:readOne catalogue item by its Variant id.
GET /v1/stockinventory:readOn-hand quantity for every item at one Location. locationId is required.
GET /v1/customerscustomers:manageCustomers, paged. ?q= is a contains match on name or phone, not on email.
POST /v1/customerscustomers:manageCreate a customer. A phone number is unique within a Business.
GET /v1/salessales:readSale headers, paged, filterable by date, Location, status and customer.
GET /v1/sales/{id}sales:readOne sale with its lines, its payments and every refund against it.
POST /v1/salessales:createPlace an order. Prices, tax and totals are computed server-side.
POST /v1/sales/{id}/refundssales:refundTake goods back. restock: true is what puts them on the shelf again.
POST /v1/sales/{id}/voidsales:voidCancel a sale: everything still refundable comes back at once.
POST /v1/ordersincoming_orders:manageSend an order to a restaurant’s till: variant ids and whole quantities, never a price. Staff accept it with a prep time or reject it with a reason.
GET /v1/orders/{id}incoming_orders:manageWhere an order you sent stands: received, accepted, preparing, ready, picked up, rejected or cancelled.
POST /v1/orders/{id}/cancelincoming_orders:manageCancel an order while the restaurant has not decided yet. Once it is accepted or rejected, call the restaurant.

The machine-readable reference for all of them (every parameter, every field, every status) is the OpenAPI document, generated from the code that serves the requests and checked against it on every build. Point a client generator at it rather than hand-writing models.

Money and quantities

Money is an integer number of minor units, always paired with an ISO-4217 currency. There are no decimal amounts anywhere in this API, in either direction.

Ninety Egyptian pounds
{ "amountMinor": 9000, "currency": "EGP" }   // 90.00 EGP
// not 90.0, not "90.00"

This is not fussiness. In IEEE-754 binary floating point 0.1 + 0.2 is 0.30000000000000004, so a cart of three items at 19.99 can total 59.96999… and round to a figure a cent away from what the customer was charged. One cent per order is a reconciliation your shop never closes. Integers cannot do that. Convert to and from minor units exactly once, at your own boundary.

Quantities are decimal strings, not numbers, with up to six decimal places, in the Variant’s baseUnit:

Quantities
{ "quantity": "2" }          // two of something counted in pcs
{ "quantity": "1.250000" }   // 1.25 kg, at scale 6

They are strings for the same reason money is an integer: a six-decimal weight does not survive a round trip through a JSON number in every language. Parse them with a decimal type, compare them as decimals, and never with == on a float.

Prices are ours, not yours. An order line sends a variantId, a quantity and optionally a unit; the price, the tax and the totals are computed on the server from the stored catalogue. There is no field in which you can send a price.

A worked example, end to end

A customer buys two coffees online and returns one. This is the exact journey our own test suite walks on every build.

1. Find the item
curl -s "${auth[@]}" "$POINTLIT_API/products?limit=200"
# → pick the variant you sell: VARIANT_ID
#   (and follow nextCursor: a catalogue is rarely one page)
2. Check there is stock
curl -s "${auth[@]}" "$POINTLIT_API/stock?locationId=$LOCATION_ID"
# → {"variantId":"…","onHandQty":"10.000000"}
#   a snapshot, not a reservation: order anyway and handle 409
3. Place the order
ORDER_KEY=$(uuidgen)          # store this with YOUR order record

curl -s -X POST "${auth[@]}" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $ORDER_KEY" \
  "$POINTLIT_API/sales" -d '{
  "locationId": "11111111-1111-4111-8111-111111111111",
  "lines": [
    {
      "variantId": "22222222-2222-4222-8222-222222222222",
      "quantity": "2"
    }
  ],
  "payment": {
    "method": "manual_card"
  }
}'

# 201 → sale.id = SALE_ID, sale.status = "paid", sale.totalMinor = 9000
#       (if the network dies here, repeat this exact call with the
#        same ORDER_KEY: you get this same response, not a second order)
4. Read it back
curl -s "${auth[@]}" "$POINTLIT_API/sales/$SALE_ID"

# → {"sale":{…,"refundedMinor":0},
#    "lines":[{"id":"LINE_ID","quantity":"2.000000","refundedQuantity":"0.000000",…}],
#    "payments":[…],"refunds":[]}
5. The customer returns one, and it goes back on the shelf
curl -s -X POST "${auth[@]}" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  "$POINTLIT_API/sales/$SALE_ID/refunds" -d '{
  "lines": [
    {
      "billLineId": "33333333-3333-4333-8333-333333333333",
      "quantity": "1"
    }
  ],
  "reason": "customer returned one",
  "restock": true
}'

#   billLineId is the `id` of the line you read back in step 4
# 201 → {"refund":{"totalMinor":4500,"restock":true,…},
#        "billId":"SALE_ID","billStatus":"partially_refunded"}
6. Confirm the goods are back
curl -s "${auth[@]}" "$POINTLIT_API/stock?locationId=$LOCATION_ID"
# → onHandQty back up by exactly 1.000000

curl -s "${auth[@]}" "$POINTLIT_API/sales/$SALE_ID"
# → sale.refundedMinor = 4500, line.refundedQuantity = "1.000000"

Read refundedMinor on the sale and refundedQuantity on each line before refunding again: they are the running totals a further return is checked against, and an over-refund is refused with v1_write_conflict rather than clamped. To take everything back at once, use POST /v1/sales/{id}/void.

A refund never edits the sale. It writes a compensating document, and the sale’s status and totals move to match, so your records and ours tell the same story afterwards, including which of the two happened first.