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
| Parameter | Type | Description |
|---|---|---|
page | integer | Page number (default: 1) |
per_page | integer | Results per page (default: 25, max: 100) |
search | string | Search by name, SKU, barcode, weight-preset barcode or brand name |
category | string | Filter by category slug. Products in that category's subcategories are included. (store:cannabis / store:general filter by category store type) |
brand | string | Filter by exact brand name. A name that matches no brand returns an empty list. |
updated_since | ISO 8601 | Only products updated after this timestamp, plus products whose variations were. An unparseable value is ignored. |
revenue_center_id | integer | Only products visible in that revenue center |
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
| Field | Type | Notes |
|---|---|---|
price, effective_price | string | Money 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_threshold | number | Quantities are numbers, and may be fractional for weight-based products |
brand | string | The brand name, not an object |
categories | array | The categories the product is directly assigned to |
additional_barcodes | array | Extra codes that scan to this product, each { "code", "label" }. additional_barcode_codes is the same list flattened to bare codes. |
variations | array | Empty 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_grams | mixed | Present only when cannabis features are switched on for the store |
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:
| Field | Type | Meaning |
|---|---|---|
cost | string | The product's landed cost |
average_cost | string | Weighted average cost across received stock |
wholesale_price | string | The 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.
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.
| Group | Fields |
|---|---|
| Identity | name, sku, barcode, description, option_label, menu_display_name |
| Pricing | price, cost, wholesale_price, wholesale_enabled |
| Classification | product_type, unit_type, product_format, brand_id, active |
| Packaging | package_size, pack_quantity, box_quantity |
| Stock policy | low_stock_threshold, reorder_point |
| Supply | supplier_name, lot_number, expiry_date |
| Cannabis | is_cannabis_product, strain_type, thc_content, cbd_content |
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
| Limit | Value |
|---|---|
| Maximum rows per request | 1,000 |
| Key field | sku (required on every row; a row with a blank SKU is rejected) |
| Writable fields | The 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" }
}
| Field | Meaning |
|---|---|
created / updated / failed | Row counts |
created_skus / updated_skus | Which SKUs were new and which already existed |
errors[].index | The row's zero-based position in the array you sent, so you can correct and resubmit just those rows |
errors[].sku | The row's SKU, or null when the row had no SKU |
errors[].errors | Validation messages for that row |
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 key | Effect |
|---|---|
price | Sets the price. Must be a non-negative number. |
current_stock | Sets 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?
- Inventory — Stock levels and the adjustment ledger
- Purchasing & Receiving — Ordered, invoiced and received figures
- Authentication — Why
costs:readis separate