Products and Categories API
Endpoints the CloudPOS POS app uses to list, create, edit and delete products and categories, generate barcodes, load units and properties, and calculate tax.
These endpoints feed the product grid in Billing, the Product List, Create New Product, Category List and Add New Category screens, and the offline product cache.
All paths are relative to {{server}} (your tenant domain, found with find-domain, see API overview). Unless a section says otherwise, send:
Header | Value |
|---|---|
|
|
|
|
|
|
|
|
The app adds store_id (the active store, for example 2 for "متجر النجمة Store") to almost every call.
Quick reference
Method | Path | Used by |
|---|---|---|
GET |
| Billing grid, Product List, barcode scan, offline sync, quick-access tiles |
POST |
| Create New Product (Save F9 / Save and Create F8), mobile Add Product |
POST |
| Edit product dialog from Product List and Billing |
POST |
| Delete action on Product List |
GET |
| Barcode generate button on Create New Product |
GET |
| Unit picker (product, purchase) and offline cache |
GET |
| Product properties and variant attributes |
GET |
| Products that currently have an active offer |
GET |
| Category panel in Billing, Category List, offline cache |
GET |
| View category |
POST |
| Add New Category |
POST |
| Edit category |
GET |
| Taxes picker on Add New Category |
GET |
| Tax preview while adding stock |
GET /api/v1/product/executive/list-products
Purpose: returns sellable products with prices, taxes, stock batches, sale units and variants. This is the main product feed of the POS.
Screens: Billing product grid (desktop and phone), Product List, barcode scan in Billing, quick-pick tiles (quick_access=true), Settings > Resync Products and the Offline Data "Products" row.
Authentication: X-Tenant is enough. No bearer token is needed.
Query parameters
Param | Example | Effect |
|---|---|---|
|
| Stock rows and |
|
|
|
|
| Page number. |
|
| Page size. Default 500, clamped to 1–500. |
|
| Returns every product without paging. |
|
| Matches product name, item code, SKU, HSN code and translated names. |
|
| Resolves the barcode (main, sale-unit, variant or scale barcode) to one product. An unknown barcode returns no products. For weighed barcodes the product carries |
|
| Products of one category. |
|
| Only products marked for quick access (the tiles that open Select Price). |
|
| Delta sync: products (or their stock in this store) changed in the range. Also fills |
|
| Products whose base price is in the range. |
|
| One of |
|
|
|
The Product List screen also sends filter_price, filter_created_by, filter_store and filter_supplier from its filter panel.
Response
This endpoint does not use the usual status envelope. It returns product, deleted_product_ids and pagination. Captured from the demo (2 of 500 products shown; long fields trimmed):
{
"product": [
{
"product_id": 15489,
"category_id": 182,
"product_name": "milibar",
"variant_mode": false,
"has_variants": false,
"matched_variant_id": null,
"product_slug": "milibar",
"barcode": "11100051",
"sku": null,
"item_code": null,
"hsn_code": null,
"reorder_level": 5,
"sort_order": 3001,
"is_online_product": true,
"taxes": [
{ "id": 28, "name": "CGST 26", "code": "CGST26", "rate": "9", "source": "category" },
{ "id": 27, "name": "SGST2026", "code": "SGST", "rate": "9", "source": "category" }
],
"category": { "name": "snacks", "slug": "snacks" },
"sellable": 1,
"purchasable": 1,
"number_of_products_available": 47,
"unit": "PC",
"currency": "INR",
"mrp": null,
"purchase_price": "4.000",
"credit_price": null,
"min_margin_percentage": null,
"min_margin_price": null,
"price": {
"old_price": null,
"standard_unit_price": "4.000",
"base_price": "4.000",
"total_price": "4.000",
"percentage": null
},
"attachment": [
{ "product_id": 15489, "title": "snacks", "is_primary": 1, "file_path": "https://…/storage/….jpg", "alt": "snacks" }
],
"names": { "en": "milibar", "ar": "ميليبار" },
"product_props": [],
"offer_price": null,
"weight_info": null,
"stock": [
{
"id": 13283,
"product_id": 15489,
"product_variant_id": null,
"store_id": 2,
"store_name": "متجر النجمة Store ",
"supplier": "Supplier 1FUNZCART",
"quantity": "20.000",
"price": "4.000",
"sku": null,
"mrp": null,
"unit": "PC",
"tax_rate": "18.00",
"purchase_price": "4.000",
"date": "2026-06-03",
"expiry_date": null,
"pkg_mfg": null,
"rack": null,
"purchase_unit_id": null,
"purchase_qty": "20.000",
"wholesale_price": "0.000",
"wholesale_min_unit": 0,
"tax_include": 1,
"tax_include_purchase": 1,
"unit_prices": []
}
],
"sale_units": [],
"variants": [],
"product_location": ""
}
],
"deleted_product_ids": [],
"pagination": { "current_page": 7, "last_page": 10, "per_page": 500, "total": 4874 }
}The demo catalogue has 4874 products (the same number the dashboard shows), so a full download is 10 pages of 500.
Product fields
Field | Meaning |
|---|---|
| Ids. |
| Default name and translated names by language code ( |
| Codes. |
| Selling price. Prices are tax-inclusive. |
| Price after the active offer for this store, when there is one. |
| MRP, purchase price (shown only to users with purchase access), credit price. |
| Despite the names, these are the maximum discount caps (percent and amount) shown as "Max discount %" and "Max discount amount" on Create New Product. |
| Taxes that apply. |
| From the category. |
| Total stock in the requested store. |
| Low-stock threshold used by stock lists and the Non-Stock Report. |
| Property values ( |
| Filled only when the request was a scale barcode: |
| The variant a scanned barcode belongs to, so the POS can preselect it. |
| One row per stock batch in the store (see below). |
| Extra sale units (pack, box). |
| Product variants (only when PRODUCT_VARIANT_ENABLED is on). Each variant has its own SKU, barcode, price, MRP and |
| Products deleted in the |
Stock row fields
Field | Meaning |
|---|---|
| Stock id. Sent as |
|
|
| Quantity on hand (3 decimals). |
| Retail price, MRP and purchase price of this batch. |
| Tax rate used for this batch, for example |
| Whether the selling price and the purchase price include tax. |
| Wholesale price that applies when the line quantity is at least |
| Rack, expiry, manufacture date and stock date. |
| The sale unit the batch was bought in, and how many. |
| Per-sale-unit prices for this batch: |
Notes
- Billing groups stock rows by the fields in master data
STOCK_GROUPING_FIELDS(default price and unit). See Stock API. - With POS_HIDE_NONSTOCK_PRODUCT on and stock tracking on, the app hides products, batches and variants with zero stock.
- The app sends locale only as a header for this endpoint (no
?locale=).
POST /api/v1/product/create-product
Purpose: creates a product and, when a quantity is given, its opening stock in the active store.
Screens: Create New Product quick form (Product List > Add Product), Billing Add Product on phones. Save (F9) saves and closes; Save and Create (F8) saves and clears the form for the next product.
Request body
{
"name": "classic chicken bucket",
"price": "220",
"category_id": "19",
"mrp": "250",
"barcode": "111000465",
"quantity": "50",
"unit": "PC",
"purchase_rate": 150.0,
"conversion_rate_base": 1,
"item_code": "CCB-01",
"sku": "1001",
"min_margin_percentage": 10,
"min_margin_price": 20,
"product_names": [
{ "language_id": 4, "name": "دلو دجاج كلاسيك" }
],
"sale_units": [
{ "unit_id": 10599, "conversion_rate": 6, "price": 1200, "barcode": "111000466" }
],
"store_id": 2
}Values above are an example built from the demo menu (classic chicken bucket sells at 220.00 with MRP 250). Optional keys are only sent when filled.
Field | Rule |
|---|---|
| Required. |
| Required, must exist. |
| Required. Tax-inclusive selling price. |
| Required (unit name, from |
| Optional. Must be unique among active products and sale-unit barcodes of the company. |
| Optional, unique per company. |
| Optional, ≥ 0. A quantity above 0 creates opening stock. |
| Optional. Used for the opening stock. If there is opening stock and no supplier exists in the company, the server returns |
| Optional max-discount caps (≥ 0). |
| Optional translated names: |
| Optional. Each needs |
| Optional (with PRODUCT_VARIANT_ENABLED). Each: |
| Optional, integer ≥ 1. |
| The active store. Opening stock goes here. |
Response
200 with { "status": "success", "message": "Product created successfully", "data": { … } }. data has the same shape as one product in list-products (ids, names, props, stock, sale units, variants).
Errors
Code | When |
|---|---|
422 |
|
401 | Missing or expired token. |
POST /api/v1/product/edit-product/{id}
Purpose: updates a product. Every field is optional ("sometimes").
Screens: Edit product dialog from Product List and from Billing (needs permission billing.product.edit, or legacy update_product).
Request body
{
"name": "classic chicken bucket",
"slug": "classic-chicken-bucket",
"price": 220,
"mrp": 250,
"purchase_price": 150,
"category_id": 19,
"barcode": "111000465",
"unit": "PC",
"unit_id": "3310",
"store_id": 2,
"sku": null,
"min_margin_percentage": 0,
"min_margin_price": 0,
"sale_units": [],
"variants": []
}Notes
- An empty
sale_unitslist removes all sale units. The app sends it only when it means that. - A cleared max-discount field is sent as
0("no cap"). A cleared SKU is sent asnull. - Variants: omit
idto create, passidto update, passidwith"_delete": trueto remove. Attributes are replaced on every save. - Quantity cannot be edited here in a way that changes stock; use Adjust Stock instead (see Stock API).
Response: { "status": "success", "message": "…", "data": { … product … } }. Errors come back as { "status": "failed", "message": "…" } with 422 for validation.
POST /api/v1/product/delete-product
Purpose: soft-deletes a product and its stock rows.
Screen: delete action in Product List.
{ "product_id": 25011 }Result | Response |
|---|---|
Deleted |
|
Not found |
|
Product still has stock on hand |
|
Deleted ids appear in deleted_product_ids of the next delta sync, so other counters drop the product too.
GET /api/v1/product/generate-barcode
Purpose: returns the next free barcode built from the company's barcode prefix (company prop CUSTOM_BARCODE_PREFIX, default 111000).
Screen: generate button next to Barcode on Create New Product.
Auth: bearer token required. The app adds store_id.
Captured response:
{
"status": "success",
"message": "Barcode generated",
"data": { "barcode": "111000465" }
}If no prefix is configured the server answers 400 "Barcode prefix not configured or inactive".
GET /api/v1/product/list-units
Purpose: unit names for product and purchase forms. Values come from master data PRODUCT_UNITS.
Screens: unit picker on Create New Product and Create New Purchase Order, Offline Data "Units".
Captured response (trimmed). data is an object of id: name:
{
"status": "success",
"message": "Units found",
"data": {
"1989": "KG",
"1990": "LT",
"1991": "DZ",
"3310": "PC",
"6953": "PCS",
"9572": "NOS",
"10599": "BOX",
"10600": "BAG"
}
}The app sends locale as a header only for this endpoint.
GET /api/v1/product/list-product-properties
Purpose: all product properties (and their options) the company has defined. Used for product properties and variant attributes.
Captured response (trimmed):
{
"status": "success",
"message": "All product properties retrieved successfully",
"data": [
{ "id": 17, "name": "MANUFACTURER", "code": "MANUFACTURER", "type": "TXT", "options": { "NULL": "NULL" }, "label": "MANUFACTURER" },
{ "id": 18, "name": "COLOR", "code": "PRODUCT_COLOR", "type": "MLT", "options": { "black": "black", "blue": "blue", "…": "…" }, "label": "COLOR" }
]
}type is TXT (free text) or a list type such as MLT with options. The app caches the list for the session.
GET /api/v1/product/offer-products
Purpose: products that currently have an active product or category offer (20 per page).
Captured response from the demo (no active offers at that time):
{ "status": "success", "message": "List of all offer products", "data": [] }The POS applies offers from offers/pos-sync instead (see Payments, delivery and discounts API).
GET /api/v1/category/list-category
Purpose: category list with translated names, images and SEO fields.
Screens: category chips in Billing and Restaurant, Category List, parent picker on Add New Category, Offline Data "Categories".
Param | Example | Effect |
|---|---|---|
|
|
|
|
| Limits to the store's mapped categories, when the store has any. |
|
| Page number. The app loops until |
|
| Search by name, slug, or parent id. |
Captured response (2 of 134 shown):
{
"data": [
{
"id": 19,
"name": "Chicken Buckets 2",
"slug": "chicken-buckets-2",
"sellable": true,
"purchasable": true,
"parent_id": null,
"image_url": "https://…/storage/….jpg",
"icon_url": "https://…/storage/….jpg",
"parent": null,
"names": [
{ "language": "English", "code": "en", "name": "Chicken Buckets 2" },
{ "language": "Arabic", "code": "ar", "name": "Chicken Buckets" }
],
"seo": { "meta_title": null, "meta_description": null, "meta_keywords": [] }
}
],
"meta": { "current_page": 1, "from": 1, "last_page": 1, "path": "…/api/v1/category/list-category", "per_page": 1000, "to": 134 },
"links": { "first": "…?page=1", "last": "…?page=1", "prev": null, "next": null }
}This is a Laravel resource collection: data, meta and links at the top level, no status field.
GET /api/v1/category/slug/{slug}
Purpose: one category by slug (view category). Returns the category resource, or 404 "Category not found".
Example: GET {{server}}/api/v1/category/slug/chicken-buckets-2
POST /api/v1/category/add-category
Purpose: creates a category. Screen: Add New Category (Category List > Add Category).
Body: multipart/form-data.
Field | Example | Rule |
|---|---|---|
|
| Required. |
|
| Required, unique in the company. Shown as URL slug. |
|
| Sellable switch. |
|
| Purchasable switch. |
|
| Optional parent category id. |
| Optional. | |
|
| English name. |
|
| Arabic name (the translate button fills it). |
| Any other language, for example | |
|
| Optional property ids attached to the category. |
|
| Optional tax ids. The category is added to each tax's category mapping. |
|
| Active store. |
| Optional, ≥ 1. |
Response: 201
{ "status": "success", "data": { "id": 135, "name": "Chicken Buckets", "…": "…", "category_taxes": [] }, "message": "Category added successfully." }Validation errors: 422 { "status": "failed", "data": [], "message": { "slug": ["The slug has already been taken."] } }.
POST /api/v1/category/edit-category/{id}
Same multipart fields as add (name, slug, is_sellable, is_purchasable, parent_category, category_lang_name[...]). Returns { "status": "success", "message": "Category updated successfully", "data": { … } }.
GET /api/v1/tax/list-tax
Purpose: company taxes for the Taxes picker on Add New Category.
Param | Example | Effect |
|---|---|---|
|
| Only active taxes. The app always sends it. |
Response: { "status": "success", "message": "Tax List", "data": [ { "id": 28, "name": "CGST 26", "code": "CGST26", "rate": "9", "…": "…" } ] } (tax ids and names from the demo product list).
GET /api/v1/calculate-tax
Purpose: the server's tax result for one price. The app calls it while adding stock to fill the tax amount fields.
Param | Example | Rule |
|---|---|---|
|
| Required. |
|
| Required. |
|
| Required. |
|
| Optional. |
Captured response (tax disabled for that product, so the rate is 0):
{
"status": "success",
"message": "Tax Calculated",
"data": {
"initial_price": 200,
"tax_rate": 0,
"tax_amount": 0,
"price_excluding_tax": 200,
"price_including_tax": 200
}
}How the rate is chosen
- If company setting TAX_ENABLED is off, tax is 0.
- The stock row's
tax_rate, when it is above 0. - Tax ids passed with the request.
- Product taxes valid on that date.
- Category tax, only when the product has no tax of its own.
A parent tax's rate is added to its child's rate. Amounts are rounded to 3 decimals by the server.
Tax-inclusive formula, with the demo numbers
For a tax-inclusive price: tax = price × rate / (100 + rate) and price without tax = price − tax.
Line | Price | Rate | Line total | Tax in line |
|---|---|---|---|---|
classic chicken bucket × 2 | 220.00 | 18% | 440.00 | 440 × 18 / 118 = 67.12 |
crispy chicken bucket × 1 | 218.00 | 18% | 218.00 | 218 × 18 / 118 = 33.25 |
Order | 658.00 | 100.37 (net without tax 557.63) |
So calculate-tax?price=220&tax_include=1 for an 18% product gives tax_amount 33.559 and price_excluding_tax 186.441 (the server keeps 3 decimals; the POS rounds money to 2 decimals).