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. |
Descriptions
Section titled “Descriptions”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
Section titled “Product details”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-detailsContent-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: trueon a detail takes it off the product form and the shop, and leavesdetailson products without it.archived: falsebrings it back with every value.optionsonPATCHis the options in their new order. Send an option with itsidto rename, move or archive it (archived: true), and one without anidto add it. An option left out keeps its place after the ones sent.- Turning
multipleoff 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.
Related products
Section titled “Related products”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…"] }Importing from a shop
Section titled “Importing from a shop”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.
Deposits and rent
Section titled “Deposits and rent”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:
- Their own agreed amount (
PUT /customers/{customerId}/prices/{productId}). - 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 owncylinderChargeis set. - Where
chargeAudienceincludes them,tradeUnitPricefor a trade customer, elseunitPrice. 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.
Price lists and charges
Section titled “Price lists and 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…/pricesContent-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.
Deposit or rent on a price list
Section titled “Deposit or rent on a price list”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-chargesContent-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.
The charges on one cylinder
Section titled “The charges on one cylinder”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…/chargesContent-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.
Refunds on returned empties
Section titled “Refunds on returned empties”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.