Product Catalog (Mobile)#
How a paired mobile/POS device reads the merchant's active product catalog, including the current
device branch's server-calculated Available quantity. Products, categories, favorites and units
are managed in the web portal; the device catalog is read-only.
Mobile/POS callers use GET /api/v1/device/products. The existing GET /api/v1/products contract
remains for portal and Invoice callers only; do not use it from a paired device.
What the catalog is#
Reusable product and service items the merchant maintains in the portal, so a line item does not
have to be typed out each time. Selecting one fills the line's name, description, price, tax and
discount.
For inventory-tracked physical products, the response adds only the Available quantity at the
device's current active physical branch. It never exposes unit cost, aggregate On Hand or Reserved.
Headers#
The device catalog requires the ordinary user JWT, the paired device token and the tenant header:
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-idBoth tokens must be valid and the device must be active. The tenant in the user JWT must matchX-Tenant-ID.
Do not send merchantId, branchId or tenantId as query parameters. The server takes tenant,
merchant and device identity from verified authentication context, reloads the current device row
on every request, and derives its branch from that row. A branch reassignment therefore takes
effect on the next request without re-pairing. Request values cannot select another merchant or
stock location.
Fetching the catalog#
This endpoint always returns active products. A caller-supplied status does not broaden the
result and should be omitted.
| Query | Default | Description |
|---|---|---|
type |
all |
all | product | service |
search |
— | Case-insensitive partial match on name and description |
categoryId |
— | Only products in this category |
subCategoryId |
— | Only products in this sub-category |
favorite |
— | true keeps favorites only; false keeps non-favorites |
view |
all |
all | most_sold — the POS tile view, see "Tiles" below |
inventoryTracking |
all |
all | tracked | not_tracked |
sort |
newest |
newest | name | price_asc | price_desc | most_sold |
page |
1 |
Page number |
limit |
10 |
Page size; values above 200 are reduced to 200 |
Results are ordered by sort — newest first (createdAt descending) unless you ask otherwise.most_sold orders by units sold, then favorites first, then name.
{
"success": true,
"data": [
{
"id": "2f1c...",
"type": "product",
"name": "Hardware Kit",
"description": "Standard equipment bundle",
"price": 480.50,
"unit": "box",
"unitLabel": "Box",
"discountValue": 5,
"discountType": "percent",
"taxRate": 8.25,
"productCode": "KIT-STD",
"status": "active",
"isFavorite": true,
"imageUrl": "https://cdn.example.com/products/tenant-1/6f1c.../2f1c.../8d0b....png",
"maxQuantity": 10,
"soldQuantity": 37.5,
"categoryId": "9b4e7a10-2c3d-4e5f-8a9b-0c1d2e3f4a5b",
"categoryName": "Hardware",
"subCategoryId": "c7d8e9f0-1a2b-4c3d-9e8f-7a6b5c4d3e2f",
"subCategoryName": "Parts",
"sku": "HW-KIT",
"trackInventory": true,
"allowZeroStockSale": false,
"available": 7.5,
"eligible": true,
"ineligibilityReason": null,
"createdAt": "2026-07-14T09:12:00.000Z",
"updatedAt": "2026-09-02T16:40:00.000Z"
}
],
"meta": { "page": 1, "limit": 200, "total": 1 }
}price, discountValue, taxRate, maxQuantity, soldQuantity and available are JSON
numbers when present. taxRate is a percentage and may be null. Category, code, SKU, unit,
image and availability fields may also be null as described by their type.
The safe device DTO contains the fields shown above. In particular, it does not containunitCost, onHand or reserved.
discountValue is only a percentage when discountType is percent; when it is fixed the number is a money amount. Carry the type across with the value — copying the number alone turns a $20 discount into 20%.
Availability and eligibility#
available is computed by the server as max(0, On Hand - Reserved) for one active physical
branch. The mobile app must render this value as advisory state; it must not calculate Available
from other fields or accept a cashier-supplied stock location.
| Item/device state | available |
eligible |
ineligibilityReason |
|---|---|---|---|
| Tracked physical product, active physical device branch | Branch Available | true |
null |
| Tracked product, no device branch | null |
false |
DEVICE_LOCATION_REQUIRED |
| Tracked product, assigned branch is inactive, non-physical or outside the merchant | null |
false |
DEVICE_LOCATION_INACTIVE |
| Untracked product or service | null |
true |
null |
eligible describes whether the device has a usable location for that item. It is not a promise
that a requested quantity can be sold: stock may change after the read, and a tracked item can be
location-eligible with available: 0.
Product image#
imageUrl is a public URL (no auth headers needed) or null when the product has none — show a
placeholder then. The file is a JPEG, PNG, WebP or SVG; make sure the image loader you use
renders SVG (on iOS and Android that usually needs an SVG-capable loader or decoder), and draw it
as an image, never in a web view that would run it as a document. A changed image always gets a new
URL, so cache images by URL for as long as you like; a URL that stops loading means the image was
replaced or removed — re-fetch the product.
Caching and search#
limit is capped at 200 (larger values are silently reduced, not rejected). Fetch the first page with limit=200 when the POS catalog opens and filter on the device — a catalog usually fits in one page.
If meta.total is greater than what you received, the rest exists and on-device filtering would hide it. Fall back to asking the server as the user types:
search matches name and description, case-insensitively, on partial words.
Re-fetch when the POS catalog opens rather than caching across sessions; the merchant may have changed prices, favorites or branch assignment in the portal in between.
Tiles: favorites and most sold#
The POS shows two tile views over the catalog. Search still covers the whole catalog — the tiles
are a shortcut, not a filter on what can be sold.
Favorites — products the merchant marked as favorite in the portal (isFavorite: true):
Most sold — the top products by units sold:
- Returns at most 10 active products (
limitmay lower it, never raise it);pageis ignored andmeta.totalis the number returned. - Ordered by
soldQuantitydescending, then favorites, then name. Before the merchant's first sale everysoldQuantityis 0, so the view shows the favorites alphabetically, then the rest A–Z. - Re-fetch after each completed sale to re-order the tiles.
What soldQuantity counts#
Units sold of that product, summed across:
- POS sales — product lines of a sale sent with
lineItems(see Device Integration, section "Itemised sales (POS cart)"), counted once the payment is approved (card), settled (cash) or approved by the bank (ACH); - Invoices — lines picked from the catalog (carrying
productId), counted when the invoice becomes fully paid.
The unit is not converted: 2.5 gallons and 3 gallons sum to 5.5. A void, a full refund or an ACH
return takes the sale back out; a partial refund does not change the count (a refund is an
amount, and nothing says which items it covered). Misc lines are never counted. Invoices created
before this feature have no productId on their lines and are not counted.
Using a product in the POS cart#
When the cashier taps a tile or search result, keep productId and the cashier-entered quantity as
the inventory identity. The existing payment payload still requires receipt/amount fields such asname and unitPrice, and validates its subtotal separately; those fields do not choose the
inventory product, policy, availability, or location. The server-owned catalog and live device row
remain authoritative for those inventory decisions. Never send cashier-selected branchId oravailable values.
For display, the app can build a local cart line from the returned item:
| Cart line field | From |
|---|---|
type |
product |
productId |
product.id |
name |
product.name |
unit |
product.unit (may be null) |
unitPrice |
product.price |
quantity |
What the cashier enters; up to 3 decimal places (2.5) |
Show the unit beside the quantity with product.unitLabel ("2.5 Gallon").
Per-order limit. When product.maxQuantity is not null, the cart may hold at most that many
units of the product in total across its lines — adding the same product twice counts both.
Block the add or quantity change that would go over, and show the limit ("Max 10 per order").null means unlimited. Send the cart as lineItems with the payment — the current public payment
payload is documented in Device Integration, section "Itemised sales
(POS cart)". A
"Miscellaneous" line is not a product: send it as type: "misc" with a name and amount and noproductId.
The server revalidates every product line against the current catalog and current device branch.
For card/cash sale, token sale, AUTH, and ACH submission, a rejected or unavailable product-cart
validation fails closed before the processor is called. allowZeroStockSale: false rejects a
quantity above current Available; allowZeroStockSale: true permits the payment and bounds the
later deduction at current Available.
External SDK payment recording is different because the provider may already have collected the
money. A failed/unavailable validation does not reverse that payment record. When POS inventory
settlement is enabled and an external SALE later reaches finality, the server queues inventory work
without guessing a deduction. Stock or location conflicts accepted by Invoice become Pending
Review; a terminal Transaction-to-Invoice delivery failure remains operational recovery work and
never reverses the payment.
Only show the cart when the device parameter posLite.enabled (POS Lite) is true
(GET /api/v1/device/parameters). The response also carries the same value asproductCart.enabled for older app builds; productCart is a deprecated alias, not a separate
inventory or settlement switch.
Portal/Invoice catalog (unchanged)#
The following Invoice behavior continues to use GET /api/v1/products; it is not the paired-device
catalog contract above.
Filling an invoice line item#
When the user picks a product, copy these values into the line at that moment:
| Line item field | From |
|---|---|
| name | product.name |
| description | product.description (null → empty) |
| unitPrice | product.price |
| unit | product.unit, when it is not null (otherwise keep the line's own unit) |
| taxRate | product.taxRate (null → 0) |
| discount | product.discountValue |
| discountType | product.discountType (percent or fixed) |
Overwrite every one of them, an absent description included — picking product B after product A must not leave A's description on a line that now says B.
Keep product.maxQuantity with the line (it is not sent): the same per-order limit as the POS cart
applies to an invoice, summed across the invoice's lines with that productId. Block quantities
over it; the server refuses them with 400 QUANTITY_LIMIT_EXCEEDED. When editing an invoice saved
before the limit was set or lowered, the server only refuses an edit that adds more of the product,
so do not block the existing quantity — only an increase.
Then post the invoice as usual (POST /api/v1/invoices) — see Invoices. Line items go in lineItems, and the customer is given with flat customerName / customerEmail / customerPhone fields; merchantId is required.
{
"merchantId": "6f1c2a9e-3b7d-4e21-9a55-0c8f1d2b7e40",
"customerName": "John Doe",
"customerEmail": "john@example.com",
"lineItems": [
{
"productId": "2f1c...",
"name": "Hardware Kit",
"description": "Standard equipment bundle",
"quantity": 1,
"unitPrice": 480.50,
"taxRate": 8.25,
"discount": 5,
"discountType": "percent",
"amount": 480.50
}
],
"discountAmount": 24.03
}productId is optional. The invoice does not look the product up: it stores its own copy of every line. Send it whenever the line was picked from the catalog — it is how a paid invoice counts toward the product's soldQuantity. Drop it if the user then replaces the name with free text. The server recomputes each line's taxAmount and amount from quantity, unitPrice, discount and taxRate, so the amount you send is not trusted.
A line discount lowers that line's taxable base, but the invoice total only subtracts the invoice-level discountAmount. Send discountAmount as the sum of the line discounts (here 5% of 480.50 = 24.03) plus any invoice-level discount, or the copied product discount will not reduce what the customer pays.
Two rules that follow from that#
The line is a snapshot. A price changed in the portal afterwards does not change an invoice that already exists. A service added at $100 stays at $100 on that invoice even if the catalog later says $150.
Editing the line does not edit the catalog. Every copied field stays editable on the line wherever the invoice flow allows editing, and changing it there has no effect on the product. Do not write edits back.
Custom (free-text) invoice lines#
An invoice line does not have to come from the catalog. When the user types an item that is not
in it — and does not create a product — send the line without productId:
{
"name": "Site visit",
"description": "Call-out on Saturday",
"quantity": 1,
"unit": "hour",
"unitPrice": 85.00,
"taxRate": 0
}- This is a normal, supported line, not a fallback: the invoice is created exactly as with a catalog line.
nameandunitPriceare entered by the user;quantityis greater than 0 with up to 3 decimal places;unitis optional and must be one of the unit codes listed in Products (common spellings such ashoursare accepted).- It never creates a catalog product, and it does not count toward any product's
soldQuantity, even if its name happens to match a product. To count, the user has to pick the product from the catalog so the line carries itsproductId. - If the user picks a product and then retypes the name, treat the line as custom again and drop the
productId.
Errors#
| Status | Code | Meaning |
|---|---|---|
| 400 | TENANT_REQUIRED |
X-Tenant-ID is missing |
| 401 | UNAUTHORIZED |
User JWT or device token is missing, invalid, expired or revoked |
| 403 | DEVICE_SUSPENDED |
The paired device is suspended |
| 403 | DEVICE_CONTEXT_MISMATCH |
The user and device tokens do not identify the same tenant/merchant context |
| 403 | DEVICE_NOT_ACTIVE |
The device is not active |
| 404 | DEVICE_NOT_FOUND |
The persisted device no longer exists |
| 400/422 | CATALOG_REQUEST_REJECTED |
A forwarded filter is malformed or unsupported; the response does not expose Invoice-service internals |
| 502 | CATALOG_SERVICE_ERROR |
The catalog service returned another failure; retry without exposing internal details |
| 503 | CATALOG_SERVICE_UNAVAILABLE |
The catalog service timed out or could not be reached; retry later |
Product-cart preflight failures use stable violation codes in error.details.violations:
| Status | Code | Meaning |
|---|---|---|
| 404 | PRODUCT_NOT_FOUND |
Product is absent from this merchant's live catalog |
| 422 | PRODUCT_NOT_ACTIVE |
Product is inactive |
| 400 | QUANTITY_LIMIT_EXCEEDED |
Aggregated product quantity exceeds maxQuantity |
| 422 | DEVICE_LOCATION_REQUIRED |
A tracked product has no current device branch |
| 422 | DEVICE_LOCATION_INACTIVE |
The assigned branch is not a usable active physical branch |
| 409 | INSUFFICIENT_STOCK |
allowZeroStockSale is false and requested quantity exceeds Available |
| 503 | PRODUCT_CART_VALIDATION_UNAVAILABLE |
Validation could not be completed; pre-payment paths fail closed |
After a completed sale, refresh GET /api/v1/device/products; the catalog response is display
state and can be stale as soon as another sale or reservation changes the branch balance.
A portal Invoice catalog that fails to load does not block invoicing: let the user type the item
and send it as a custom Invoice line (see above). Do not apply that fallback to a POS product line:
pre-payment product-cart validation is server-authoritative and fails closed.