Skip to content

Products

Products are read with products:read and changed with products:write. The full shapes are in the API reference.

Route Permission What it does
GET /products products:read Lists products.
GET /products/{id} products:read One product.
POST /products products:write Adds one.
PATCH /products/{id} products:write Changes what is sent.
PUT /products/import products:write Adds or updates one, matched on a shop’s id.
GET /products/{id}/prices products:read Every price it has: Consumer, Trade, each price list.
PUT /products/{id}/prices products:write Sets its quantity prices and price list prices.
PUT /rate-sets/{id}/prices products:write Replaces one price list’s prices.
PUT /rate-sets/{id}/cylinder-charges products:write Chooses deposit, rent or neither on each gas type.
GET /product-details products:read Lists the business’s product details.
POST /product-details products:write Adds one.
PATCH /product-details/{id} products:write Renames it, changes options, archives it.
PUT /product-details/order products:write Puts them in order.
POST /product-details/{id}/products products:write Sets one detail on many products.

A product has its description in two forms:

  • descriptionHtml: the description with its formatting, as HTML.
  • description: the same words as plain text, with a blank line between paragraphs and a line starting - for each list item. Use it for search, previews, meta descriptions and anywhere HTML cannot go.

Send descriptionHtml to set a formatted description. Bottle sanitises it on the server every time, whoever sends it. Only these tags are kept:

Tag Kept as
p, br Paragraphs and line breaks.
h2, h3 Headings. h1 becomes h2; h4 to h6 become h3.
strong, em Bold and italic. b and i become strong and em.
ul, ol, li Bulleted and numbered lists.
a Links to http, https or mailto addresses.

Everything else goes. A tag that is not on the list is removed and its words are kept. Scripts, styles, noscript, template, frames, objects and embeds, svg, math, forms and inputs, pictures, video and audio, base, meta and link are removed with everything inside them. Every attribute is removed except a link’s href. Bottle sets rel="noopener noreferrer nofollow" on every link, and target="_blank" on a link to a web page. A link to anything else, such as javascript:, vbscript:, data:, a relative or protocol-relative address, including ones hidden with entities or control characters, keeps its words and loses the link. Empty paragraphs are dropped, and a description with no words left is null.

A description may be sent as up to 50,000 characters and kept as up to 20,000 once sanitised. Over either is refused with 400 (description_too_long), and nothing is changed.

If you show descriptionHtml yourself, treat it as HTML from a third party and sanitise it with your own allowlist too. description is plain text: escape it like any other text.

Send plain description instead and Bottle escapes it and makes paragraphs from it: a blank line starts a new paragraph. When both are sent, descriptionHtml wins. Send null to clear both. Leave both out and the description is not changed.

PATCH /products/0192…
Content-Type: application/json
{
"descriptionHtml": "<h2>What you get</h2><p>A full <strong>13kg</strong> propane cylinder.</p><ul><li>Same day delivery</li></ul>"
}

A product saved before descriptions had formatting has only plain text. Its descriptionHtml is made from that text, escaped and in paragraphs.

Product details are facts a business records about its products in its own words, such as a regulator’s fitting or a cylinder’s capacity. A choice, a number or yes or no has its answers fixed in advance, so the shop can filter by it; a text detail is a line typed on each product and is never a filter. Each detail has a type:

type Value on a product Extra fields
choice An array of option ids options, multiple
number A number unit, such as kg
yesno true or false
text A string, up to 200 characters; never a filter

kinds lists the kinds of product a detail is for, or is null for every product. key is made from the name when the detail is created and kept on a rename; the shop uses it in filter addresses, such as /products?d-regulator-fitting=clip-on.

POST /product-details
Content-Type: application/json
{ "name": "Regulator fitting", "type": "choice", "options": ["Clip-on", "Screw-fit"], "kinds": ["accessory"] }

A detail’s type stays as it was made. Nothing deletes a value a product has:

  • archived: true on a detail takes it off the product form and the shop, and leaves details on products without it. archived: false brings it back with every value.
  • options on PATCH is the options in their new order. Send an option with its id to rename, move or archive it (archived: true), and one without an id to add it. An option left out keeps its place after the ones sent.
  • Turning multiple off while a product has more than one option is refused with 409.

A product’s details lists its values, in the details’ order, for details that are not archived. Send details on POST /products, PATCH /products/{id} or PUT /products/import to set them. Only the details sent change; null, an empty array or an empty string clears one.

PATCH /products/0192…
Content-Type: application/json
{ "details": [{ "detailId": "0192…", "value": ["0192…"] }, { "detailId": "0192…", "value": 13 }] }

A detail that is not the business’s, or is archived, is refused with 404. A value of the wrong type, an option the detail does not have, or two options on a detail that takes one is refused with 422. A detail sent for a kind of product it is not for is refused with 409.

POST /product-details/{id}/products gives many products the same value, or clears it with null. It answers with how many it set and, in skipped, the products the detail is not for, which are left as they were. Every product must be the business’s, or nothing changes and the answer is 404.

The shop’s own site (GET /storefront/sites/{host}) carries the details that are not archived as productDetails, with a key for each option, and each product’s values in details, by detail key. Which filters a shop shows, and how it sorts at first, are in its settings as listing.

relatedProductIds lists the products shown under this one on the business’s shop, in order. Send up to 8 to replace the list, or an empty list to go back to the shop’s own choice of others from the same category.

  • Each id must be one of the business’s products, or the request is refused with 404.
  • The product itself and repeats are dropped.
  • A picked product is shown on the shop only while it is on the shop and can be bought: published, not archived, and not out of stock with the shop set to stop selling. A product that is deleted is removed from every list it was in.
PATCH /products/0192…
Content-Type: application/json
{ "relatedProductIds": ["0192…", "0192…"] }

PUT /products/import and a pull from a connected WooCommerce or Shopify shop treat a description the same way. It is sanitised as above. On the first import it is written as sent. On later imports it replaces the description in Bottle only while nobody has changed that in Bottle since the last import. A description edited in Bottle is kept, and a pull lists those products in descriptionsKept.

A pull takes WooCommerce’s long description, or the short one when there is no long one, and Shopify’s descriptionHtml. A shop description over 20,000 characters once sanitised is not brought in: the rest of the product is, and the pull lists it in descriptionsTooLong.

WooCommerce catalogue listings also carry an optional vatRate, resolved from the product or variation tax class and the shop’s tax-rate table. The empty WooCommerce class means standard; a variation can inherit its parent’s class. Only an unambiguous rate is imported. If a class has several location-based rates, or the connection cannot read the tax table, the field is omitted and a later pull does not overwrite the product’s Bottle rate. Shopify listings omit the field because its catalogue API does not supply an equivalent product rate.

A deposit or rent is a product with charge set to deposit or hire. Its scope is either one whole gas type, set with gasTypeId, or any number of exact cylinder products in chargeForProductIds. Exact products may belong to different gas types. When chargeForProductIds is not empty, it is the full scope and gasTypeId is null. chargeForProductId is the backwards-compatible single-product field: Bottle mirrors it when exactly one product is selected. chargeAudience says who pays it where no price list or agreed amount says otherwise:

Value Who pays
both Consumer and Trade customers. The default.
consumer Everybody who is not a trade customer, signed in or not.
trade Trade customers only.

Set it with POST /products or PATCH /products/{id}. It is ignored on anything that is not a charge.

What one customer pays for a charge is the first of these that applies:

  1. Their own agreed amount (PUT /customers/{customerId}/prices/{productId}).
  2. Their price list’s say: an amount, or notCharged, in which case they pay nothing. Where a gas type has both a deposit and rent, the list’s choice between them (see below), unless the customer’s own cylinderCharge is set.
  3. Where chargeAudience includes them, tradeUnitPrice for a trade customer, else unitPrice. A customer it leaves out pays nothing.

Orders, the shop basket, rent invoices and GET /stock/rent all use this. Price rules never apply to charges.

GET /products/{id}/prices works for a deposit or rent. The answer has charge and chargeAudience, no quantity breaks, and notCharged on each price list row. PUT /products/{id}/prices takes notCharged: true on a price list to say its customers do not pay the charge. Quantity breaks on a charge, or notCharged on anything that is not a charge, are refused with 422.

PUT /products/0192…/prices
Content-Type: application/json
{
"consumerBreaks": [],
"tradeBreaks": [],
"priceLists": [
{ "rateSetId": "0192…", "price": { "unitPrice": 2000, "priceIncludesVat": false }, "breaks": [] },
{ "rateSetId": "0192…", "price": null, "notCharged": true, "breaks": [] }
]
}

PUT /rate-sets/{id}/prices takes deposits and rent too, with the same notCharged. Each product must be the business’s own, or the request is refused with 422. POST /rate-sets/{id}/prices/fill leaves a list’s deposit and rent amounts as they are.

Where a gas type (or a size with a charge of its own) has both a deposit and rent, a price list charges one of them or neither, never both. Its choice is stored as its rows on the two charges:

Choice Deposit row Rent row
usual none none
deposit the list’s amount, or none notCharged
rent notCharged the list’s amount, or none
none notCharged notCharged

So an amount on one of the two needs the other notCharged. Any write that would break this, on PUT /rate-sets/{id}/prices, PUT /products/{id}/prices or PUT /rate-sets/{id}/cylinder-charges, is refused with 422 and the error charges_both. The one chosen with no row of its own is charged at the usual amount, even to a customer its chargeAudience leaves out.

GET /rate-sets/{id} reads the rows back per gas type in cylinderCharges:

{
"gasTypeId": "0192…",
"sizeId": null,
"name": "Propane",
"depositId": "0192…",
"rentId": "0192…",
"choice": "deposit",
"amount": { "unitPrice": 2000, "priceIncludesVat": false }
}

PUT /rate-sets/{id}/cylinder-charges sets them the same way. A gas type or size left out stays as it is. amount is only taken with deposit or rent; null is the usual amount. A gas type with no charge, or a choice of a charge it does not have, is refused with 422 and the error unknown_charge.

PUT /rate-sets/0192…/cylinder-charges
Content-Type: application/json
{
"charges": [
{ "gasTypeId": "0192…", "choice": "rent", "amount": { "unitPrice": 150, "priceIncludesVat": false } },
{ "gasTypeId": "0192…", "choice": "none" }
]
}

On GET /products/{id}/prices for a deposit or rent, each price list row has listChoice: what that list chose, where the gas type has the other charge too, else null. A row whose list chose usual or the other charge is changed on the price list; leave it out of PUT /products/{id}/prices to keep it.

A customer whose own cylinderCharge is deposit or rent keeps it whatever their list chose: they pay that charge at the list’s amount where the list has one, else the usual amount. Their own agreed amount still comes first of all.

GET /products/{id}/charges (products:read) says which deposit and which rent a size is charged, found the way orders and invoices find them: an exact product charge (chargeForProductIds) wins over its gas type’s, each kind on its own. Each entry has kind (deposit or rent), the full charge product, from (gas_type or size), alsoOn (the other sizes that pay a gas type’s charge), instead (the gas type’s charge a size’s own one replaces) and priceLists (each list, not archived, with its own amount or notCharged). eachPays is the business’s choice where both apply. Anything that is not a size of a gas type answers gasType: null and no charges. GET /gas-types/{id}/charges answers the same for a size not saved yet.

POST /products/{id}/charges (products:write) puts an existing charge on the size (appliesTo: "size") or its whole gas type ("gas_type"):

POST /products/0192…/charges
Content-Type: application/json
{ "chargeId": "0192…", "appliesTo": "gas_type", "name": "Flogas Propane rent" }

A charge on no gas type is moved there. A charge already assigned to exact products is extended when it is added to another product. One set on a whole gas type is copied when necessary, with its amounts, period, VAT, who pays it and refund rules, and the other is left alone; name names the copy. A place that already has that kind is a 409 conflict; a product that is not a cylinder is a 409 not_a_cylinder. A price list with its own amount on the other charge of those cylinders gets the new one as not charged, so it goes on charging what it did. POST /products and PATCH /products/{id} do the same when a charge lands on a gas type or exact products. Every id in chargeForProductIds must be one of the business’s own cylinder products (404 otherwise). Up to 500 can be selected.

DELETE /products/{id}/charges/{chargeId} takes a charge the size pays off its place: the size, for its own, or the whole gas type, for a gas type’s. The charge is kept with no gas type. A charge the size does not pay is a 409.

Each cylinder going out to a customer records whether a charge applied. A deposit is refunded only on a cylinder that went out with one, so a customer who was not charged a deposit is never refunded one. Changing chargeAudience later does not change what was already paid or what is refunded on it. Rent follows chargeAudience and price lists as they are on each invoice. Where a gas type has both, a cylinder recorded before charges were kept counts as the customer’s own choice, else their price list’s, else the business default.