Skip to main content
Sign in with your store to see only the help that applies to you.

Products API

Required scope: products:read (read) · products:write (write) · costs:read (cost fields)


List Products​

GET /api/v1/products

Returns a paginated list of active products, sorted by name.

Query Parameters​

ParameterTypeDescription
pageintegerPage number (default: 1)
per_pageintegerResults per page (default: 25, max: 100)
searchstringSearch by name, SKU, barcode, weight-preset barcode or brand name
categorystringFilter by category slug. Products in that category's subcategories are included. (store:cannabis / store:general filter by category store type)
brandstringFilter by exact brand name. A name that matches no brand returns an empty list.
updated_sinceISO 8601Only products updated after this timestamp, plus products whose variations were. An unparseable value is ignored.
revenue_center_idintegerOnly products visible in that revenue center
Which rows are returned

Without search or updated_since, only top-level products are listed — variations appear nested in each parent's variations array. When search or updated_since is used, variation products can also appear as their own rows.

Example​

curl -H "X-API-Key: bpos_..." \
"https://yourstore.brotherpos.ca/api/v1/products?category=edibles&per_page=50"

Response​

{
"data": [
{
"id": 1,
"name": "Blue Dream 3.5g",
"sku": "BD-35",
"barcode": "628123456789",
"additional_barcodes": [{ "code": "628999111222", "label": "Case UPC" }],
"additional_barcode_codes": ["628999111222"],
"description": "Sativa-dominant hybrid",
"product_type": "simple",
"price": "35.00",
"effective_price": "31.50",
"on_sale": true,
"exclude_from_promotions": false,
"block_manual_discounts": false,
"unit_type": "unit",
"current_stock": 24.0,
"in_stock": true,
"low_stock_threshold": 5.0,
"brand": "Broken Coast",
"categories": [
{ "id": 3, "name": "Flower", "slug": "flower" }
],
"package_size": "3.5g",
"has_variations": false,
"variations": [],
"primary_image_url": "https://yourstore.brotherpos.ca/...",
"active": true,
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-03-20T14:30:00Z",
"strain_type": "sativa",
"thc_content": 22.5,
"cbd_content": 0.1,
"equivalent_grams": 3.5
}
],
"pagination": { "current_page": 1, "per_page": 50, "total_count": 142, "total_pages": 3 },
"meta": { "request_id": "...", "timestamp": "2026-03-22T12:00:00Z" }
}

Field Notes​

FieldTypeNotes
price, effective_pricestringMoney is serialised as a fixed two-decimal string ("35.00"), not a float, so no precision is lost in transit. Parse it as a decimal, not a JSON number.
current_stock, low_stock_thresholdnumberQuantities are numbers, and may be fractional for weight-based products
brandstringThe brand name, not an object
categoriesarrayThe categories the product is directly assigned to
additional_barcodesarrayExtra codes that scan to this product, each { "code", "label" }. additional_barcode_codes is the same list flattened to bare codes.
variationsarrayEmpty unless has_variations is true. Each active variation carries id, name, sku, barcode, additional_barcodes, additional_barcode_codes, price, current_stock and in_stock.
strain_type, thc_content, cbd_content, equivalent_gramsmixedPresent only when cannabis features are switched on for the store
Null fields are omitted

Top-level fields with no value (for example barcode, brand or option_label) are dropped from the response rather than sent as null. Do not assume a key is present — read defensively.


Cost and Margin Fields​

Three fields are returned only to a key that holds the costs:read scope:

FieldTypeMeaning
coststringThe product's landed cost
average_coststringWeighted average cost across received stock
wholesale_pricestringThe price used for wholesale/B2B sales
{
"id": 1,
"name": "Blue Dream 3.5g",
"price": "35.00",
"cost": "12.00",
"average_cost": "11.75",
"wholesale_price": "18.00"
}

Without costs:read these keys are absent from the payload entirely — not present with a null or 0 value. That is intentional: an absent field cannot be mistaken for a cost of zero and quietly turned into a 100% margin in someone's report. Webhook payloads never include them.

Why cost is its own scope

products:read is a low-sensitivity read — a menu board, a website feed, a price comparison. Cost is not: paired with the price it publishes your margin on every line. Keeping them separate lets you share the catalogue with an agency or a reporting vendor without also handing over what you paid. See Authentication.


Get a Product​

GET /api/v1/products/:id

Returns a single product in the same shape as the list. Unlike the list, this also returns inactive products; a deleted product returns 404.


List Categories​

GET /api/v1/categories

Required scope: products:read

Returns the active category tree as an array of top-level categories, each with nested subcategories. Not paginated. Categories that contain no active products (directly or through a subcategory) are left out.

Each category includes id, name, full_name (e.g. "Flower > Indica"), slug, icon, icon_url, color, position, store_type, age_restricted, depth, parent_id, ancestor_ids, revenue_center_ids, product_count, total_product_count and subcategories. Pass revenue_center_id to limit the tree to one revenue center.


List Brands​

GET /api/v1/brands

Required scope: products:read

Returns every brand, sorted by name, each with id, name and description. Not paginated.

{
"data": [ { "id": 4, "name": "Broken Coast", "description": null } ],
"meta": { "request_id": "...", "timestamp": "2026-03-22T14:30:00Z" }
}

A request without a valid API key gets 401 Unauthorized; a key without products:read gets 403 Forbidden.


Create a Product​

POST /api/v1/products

Required scope: products:write

Creates a single product in the store the API key belongs to.

Request Body​

Wrap the attributes in a product object:

curl -X POST \
-H "X-API-Key: bpos_..." \
-H "Content-Type: application/json" \
-d '{
"product": {
"name": "Blue Dream 3.5g",
"sku": "BD-35",
"price": 35.00,
"cost": 12.00,
"unit_type": "unit",
"product_type": "simple",
"low_stock_threshold": 5
}
}' \
https://yourstore.brotherpos.ca/api/v1/products

Writable Fields​

Only these fields may be set through the API. Anything else in the payload is ignored — products carry internal state (stock, sync flags, quality scores) that must not be settable from outside.

GroupFields
Identityname, sku, barcode, description, option_label, menu_display_name
Pricingprice, cost, wholesale_price, wholesale_enabled
Classificationproduct_type, unit_type, product_format, brand_id, active
Packagingpackage_size, pack_quantity, box_quantity
Stock policylow_stock_threshold, reorder_point
Supplysupplier_name, lot_number, expiry_date
Cannabisis_cannabis_product, strain_type, thc_content, cbd_content
Stock is not settable here

current_stock is not a writable field on create or upsert. A product created through this endpoint starts at zero. Add stock with a stock adjustment (or Bulk Update), so the movement is recorded in the ledger.

Response​

201 Created, with the product in the same shape as a read (cost fields included only if the key holds costs:read):

{
"data": {
"id": 812,
"name": "Blue Dream 3.5g",
"sku": "BD-35",
"price": "35.00",
"current_stock": 0.0,
"in_stock": false,
"active": true
},
"meta": { "request_id": "...", "timestamp": "2026-08-19T12:00:00Z" }
}

On a validation failure the response is 422 Unprocessable Entity:

{
"error": "Sku has already been taken",
"errors": { "sku": ["has already been taken"] }
}

A body without a product object returns 400 Bad Request.


Bulk Upsert Products​

POST /api/v1/products/bulk_upsert

Required scope: products:write

Loads a batch of products in one request, keyed on SKU. This is the endpoint to use for a catalogue migration or a nightly catalogue push from another system.

Idempotent: safe to re-run​

Each row is matched to an existing product by its sku. If a product with that SKU already exists it is updated; if not, it is created. Sending the same payload a second time therefore updates the same products rather than producing a second copy of your catalogue.

That matters because a real migration is never one clean pass — a batch dies halfway, someone re-uploads the file, a column gets corrected and it runs again. Re-running is routine, not a cleanup job.

Deleted products are not matched, so a row whose SKU belonged to a deleted product creates a new product.

One bad row does not fail the batch​

Rows are saved independently. A row that fails validation is reported with its position and its errors, and every other row still lands. Four thousand good products do not get rejected because one price was malformed.

Limits​

LimitValue
Maximum rows per request1,000
Key fieldsku (required on every row; a row with a blank SKU is rejected)
Writable fieldsThe same list as Create a Product

Send more than 1,000 rows and the whole request is rejected with 422 and "at most 1000 products per request" — chunk larger catalogues client-side. A missing or empty products array returns 422 with "products must be a non-empty array".

Request Body​

curl -X POST \
-H "X-API-Key: bpos_..." \
-H "Content-Type: application/json" \
-d '{
"products": [
{ "sku": "BD-35", "name": "Blue Dream 3.5g", "price": 35.00, "cost": 12.00 },
{ "sku": "GG4-1", "name": "Gorilla Glue 1g", "price": 12.00 },
{ "sku": "PK-7", "name": "Pink Kush 7g", "price": 64.00 },
{ "sku": "AK-14", "name": "AK-47 14g", "price": -5 }
]
}' \
https://yourstore.brotherpos.ca/api/v1/products/bulk_upsert

Response​

The response is multi-status: 200 OK when every row succeeded, 207 Multi-Status when at least one row failed. Always inspect the body — a 207 means part of your batch did not land.

{
"data": {
"created": 2,
"updated": 1,
"failed": 1,
"created_skus": ["GG4-1", "PK-7"],
"updated_skus": ["BD-35"],
"errors": [
{
"index": 3,
"sku": "AK-14",
"errors": ["Price must be greater than or equal to 0"]
}
]
},
"meta": { "request_id": "...", "timestamp": "2026-08-19T12:00:00Z" }
}
FieldMeaning
created / updated / failedRow counts
created_skus / updated_skusWhich SKUs were new and which already existed
errors[].indexThe row's zero-based position in the array you sent, so you can correct and resubmit just those rows
errors[].skuThe row's SKU, or null when the row had no SKU
errors[].errorsValidation messages for that row
Resubmitting failures

Fix the rows named in errors and post them again on their own. Because the upsert is keyed on SKU, the rows that already succeeded are unaffected whether you include them or not.


Bulk Update Products​

PATCH /api/v1/products/bulk_update

Required scope: products:write

Update price or stock for multiple products at once, selected by ID. Use this for a targeted change to products you already know; use Bulk Upsert when you are loading a catalogue and only know SKUs.

Request Body​

{
"product_ids": [1, 2, 3],
"updates": {
"price": 29.99
}
}
updates keyEffect
priceSets the price. Must be a non-negative number.
current_stockSets stock on hand to this value
stock_adjustment{ "type": "set" | "add" | "subtract", "amount": 5 } — subtract never goes below zero

A stock change made this way is recorded in the stock adjustment ledger (as a receive for an increase, a count for a decrease).

Response​

200 OK when every product saved:

{ "data": { "success": true, "updated_count": 3, "message": "Successfully updated 3 product(s)" }, "meta": { ... } }

If any product failed, the status is 422 and the (unwrapped) body lists them. Products that did save stay saved:

{
"success": false,
"updated_count": 2,
"total_count": 3,
"errors": [ { "id": 3, "name": "Pink Kush 7g", "errors": ["Price must be a valid non-negative number"] } ]
}

A missing or non-array product_ids returns 422 (No products selected); IDs that match no products return 404 (No products found).


What's Next?​