Developer dashboard

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:

http
Authorization: Bearer <user_jwt>
X-Device-Token: <device_token>
X-Tenant-ID: your-tenant-id

Both tokens must be valid and the device must be active. The tenant in the user JWT must match
X-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#

GET/api/v1/device/products?limit=200

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.

json
{
  "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 contain
unitCost, 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.

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:

GET/api/v1/device/products?search=hardware&limit=200

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):

GET/api/v1/device/products?favorite=true&sort=name&limit=200

Most sold — the top products by units sold:

GET/api/v1/device/products?view=most_sold
  • Returns at most 10 active products (limit may lower it, never raise it); page is ignored and meta.total is the number returned.
  • Ordered by soldQuantity descending, then favorites, then name. Before the merchant's first sale every soldQuantity is 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 as
name 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 or
available 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 no
productId.

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 as
productCart.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.

json
{
  "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:

json
{
  "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.
  • name and unitPrice are entered by the user; quantity is greater than 0 with up to 3 decimal places; unit is optional and must be one of the unit codes listed in Products (common spellings such as hours are 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 its productId.
  • 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.