Skip to content

Shop and stock

These routes sit behind Settings > Shop and the stock panel on a product. They need the shop on your plan for the delivery areas, and the usual product permissions for stock. The full shapes are in the API reference.

Where your shop delivers, by postcode. Each area uses one of your delivery options for its days and its charge, so the shop promises what the office already offers.

Route Permission What it does
GET /storefront/delivery-areas storefront:read Lists the areas, in your order.
POST /storefront/delivery-areas storefront:write Adds one.
PATCH /storefront/delivery-areas/{id} storefront:write Changes what is sent.
DELETE /storefront/delivery-areas/{id} storefront:write Stops delivering there.
POST /storefront/delivery-areas
Content-Type: application/json
{
"name": "Thirsk and villages",
"patchIds": ["0192…"],
"deliveryOptionIds": ["0192…", "0192…"],
"freeOverPence": 6000,
"minimumOrderPence": 2000
}
  • An area is made of your delivery areas (patches in the API), so the shop delivers where your drivers already go. A patch’s areas match an address the way they do for rounds: postcode terms (LS, LS1) by prefix, towns and counties by name.
  • When patches in two areas both match, the more specific one wins: a district over a postcode area, a postcode area over a town name.
  • A patch can be in one area only. Adding it to a second is refused with a 409 that names the patch and the area that has it.
  • deliveryOptionIds are the choices shoppers there get, in your order: say next day at one price and three days free. Each option’s own order-by time decides when its days start counting from tomorrow.
  • freeOverPence makes delivery free once the products in the basket, with VAT, reach it. Deposits do not count towards it. Null is never free.
  • minimumOrderPence is the smallest basket delivered there, with VAT. Null uses the shop’s own minimum.
  • Removing an area keeps it behind the scenes, so a basket already priced against it still reads.

A plain count for things sold on the shop that are not cylinders: hoses, regulators, BBQs, bags of coal. Cylinders are counted under Stock as before. A product nobody counts is always available.

Route Permission What it does
GET /products/{id}/stock products:read The count, its rules, and the last 50 changes.
POST /products/{id}/stock products:write Counts the shelf, or moves the count up or down.
DELETE /products/{id}/stock products:write Stops counting. The history is kept.
POST /products/0192…/stock
Content-Type: application/json
{ "count": 24, "reason": "adjustment", "note": "Counted on Friday" }

Send count with what is on the shelf, or change with how many came in (plus) or went (minus), never both. A count is also how counting starts. A change on a product that is not counted is refused with a 409: count it first. reason is restock for a delivery in, adjustment for anything else.

Every change is kept as a movement with what it changed by and the count after it. The shop adds its own: sale when an order is paid, and cancelled when the office cancels a shop order and the stock goes back.

Two settings on the product itself, sent with POST /products or PATCH /products/{id}:

  • stockLowAt: at or under this, the office is told (the bell and an email) after a sale, and the shop may say low stock.
  • whenOut: stop takes the product off sale when none are left, continue keeps selling it as ships when back in stock. Null follows the shop’s own rule under Settings > Shop.

The shop never shows the number, only in stock, low stock, out of stock or ships when back in stock.

A product can carry a trade price, tradeUnitPrice with tradePriceIncludesVat, sent with POST /products or PATCH /products/{id}. On your shop, a signed-in trade customer pays the rate you agreed with them first, then the trade price, then the list price. Households always pay the list price. Where you are VAT registered, trade customers see prices without VAT.

Shoppers can ask for a trade account from their account on your shop. You get an email and a notice, and answer it here:

Route Permission What it does
GET /trade-applications?status=pending customers:read The requests, newest first.
GET /trade-applications/{id} customers:read One request.
POST /trade-applications/{id}/approve customers:write { "customerId": "…" } to join an existing trade customer, or { "create": true, "paymentTerms": "on_account" } to make one.
POST /trade-applications/{id}/decline customers:write { "reason": "…" }, which the shopper is told.
POST /customers/{id}/shopper-accounts/{accountId}/unlink customers:write Stops a shop sign-in buying for that customer.

A request can name colleagues in contacts, each with an email and a role (buyer, the default, or accounts). Approving it joins the shopper as the customer’s manager and invites each colleague to their own sign-in, skipping the shopper and anybody already on the customer. The response lists who was invited in invited.

GET /customers/{id} lists the shop sign-ins that buy for the customer, in shopperAccounts. A trade customer on pay-later terms can order on account from the shop with no payment, and the order is invoiced on their terms.

Each person buying for a trade customer has their own sign-in and a role: manager (orders, invoices, colleagues), buyer (orders, and their own orders only) or accounts (invoices only, cannot order). Invite somebody with POST /customers/{id}/shopper-invitations { "email", "role" }, take an invitation back with DELETE /customers/{id}/shopper-invitations/{invitationId}, and change a role with PATCH /customers/{id}/shopper-accounts/{accountId}. A customer always keeps one manager. POST /customers/{id}/send-rates emails the customer every price they pay on your shop.

Every price a product has, in one table: the Consumer price, the Trade price, then one row per price list, each with its quantity breaks. In the API a price list is a rate set. Products permissions, the same as products.

Route Permission What it does
GET /products/{id}/prices products:read The table: consumer, trade and priceLists, each with breaks.
PUT /products/{id}/prices products:write Replaces the breaks on each row and sets or clears each price list’s price sent.
PUT /products/0192…/prices
Content-Type: application/json
{
"consumerBreaks": [],
"tradeBreaks": [{ "fromQuantity": 10, "unitPrice": 3000, "priceIncludesVat": false }],
"priceLists": [
{
"rateSetId": "0192…",
"price": { "unitPrice": 3400, "priceIncludesVat": false },
"breaks": [{ "fromQuantity": 10, "unitPrice": 2900, "priceIncludesVat": false }]
}
]
}
  • A break is “from this many in one order, every one of them is this unit price”. Breaks start at 2 or more, each from more than the last, at most ten a row. Pence as typed, with priceIncludesVat.
  • The Consumer and Trade prices themselves change with PATCH /products/{id} (unitPrice, tradeUnitPrice). A null trade price means trade customers pay the Consumer price.
  • A price list’s price: null takes its price off: its customers pay the Trade price. A price list left out of priceLists is left as it is. An unknown list is 404, an archived one 409.
  • Deposits, rent and delivery have no price table (422).

Who pays what, in three steps: the customer’s own agreed price, then their price list’s price, then the Trade price for trade customers or the Consumer price for everyone else. Quantity breaks go with whichever price applies. Quantity counts per product across the whole order.

Office orders, standing orders and sales from the van are priced this way. A typed unitPrice on a line still wins. On the shop, each product on the site (and in a signed-in customer’s /account/prices) can carry volume, its breaks with VAT in and out.

Under the table, each row’s breaks are kept as a price rule: scope: "product", counting: "bulk", fixed steps, and audience consumer, trade, rate_set (with rateSetId) or customer (with customerId). A customer’s own breaks are made here directly.

Route Permission What it does
GET /price-rules products:read Lists rules, narrowed by audience, customerId or productId.
POST /price-rules products:write Adds one.
GET /price-rules/{id} products:read One rule.
PUT /price-rules/{id} products:write Replaces it whole.
DELETE /price-rules/{id} products:write Deletes it. Set active: false to pause one.
POST /price-rules
Content-Type: application/json
{
"audience": "customer",
"customerId": "0192…",
"scope": "product",
"productId": "0192…",
"counting": "bulk",
"steps": [{ "kind": "fixed", "fromQuantity": 10, "unitPrice": 3700, "priceIncludesVat": false }]
}

The API still reads wider rules (scope of gas_type, category or all), stepped counting and percent_off or cost_plus steps, and prices orders by them, but the app no longer makes them. A customer, product, gas type or category that is not this business’s is a 422 with error: "unknown_target" and the field.

A price list, such as Gold, List B or Pubs, that a group of customers is on. Products permissions for the lists and their prices; moving customers needs customers:write.

Route Permission What it does
GET /rate-sets products:read The lists, with memberCount and priceCount. includeArchived=true for all.
POST /rate-sets products:write Adds one: name, optional colour and notes.
GET /rate-sets/{id} products:read One list with its prices.
PATCH /rate-sets/{id} products:write Changes what is sent.
DELETE /rate-sets/{id} products:write Archives it and takes every customer off it.
PUT /rate-sets/{id}/prices products:write Replaces the list’s price on every product.
POST /rate-sets/{id}/prices/fill products:write { "percentOff": 10 }: every product at its Trade price (else Consumer) less that.
POST /customers/rate-set customers:write Puts many customers on a list, or off theirs with rateSetId: null.
GET /customers/ids customers:read Every matching id for the list’s filters, up to 5000.
  • A list prices only things you sell, never deposits, rent or delivery (422 otherwise). A product it leaves out charges its customers the Trade price.
  • The fill replaces what the list had, rounds to the penny, and keeps the VAT basis each price was typed in. Each price then shows, and can be changed, on the product.
  • POST /customers/rate-set with dryRun: true only counts. With clearAgreedRates: true it removes the moved customers’ agreed prices on the products the list prices, which would otherwise still beat it. It is all or nothing: an unknown customer or list is 404, an archived list 409.
  • A customer is on one list or none. GET /customers?rateSet= lists a list’s customers, or none for customers on no list. PATCH /customers/{id} takes rateSetId.

A deposit (a product with charge: "deposit") can carry its own refund rule: refundRule (full, percent, scale, none, or null for your usual setting), refundPercent and refundDeductionPence for percent, refundScale for scale (bands in ascending months, each with percent or fixedPence, the last may have upToMonths: null, at most ten), and refundCountsFrom (latest or first). A rule on anything but a deposit is refused with a 409.

Cylinders a customer had before you used Bottle can be recorded with POST /customers/{id}/holdings { "productId", "quantity", "since" }, so their refunds (and their holdings) count from that day.

On your shop, a cylinder with a deposit says in one sentence how its deposit comes back.

Shoppers pay on Stripe’s own payment page, on your business’s Stripe account. Card details are typed into Stripe and never reach Bottle. Your shop asks Bottle to price the basket from your own prices (nothing the browser says about a price is used), then Bottle asks Stripe for a payment page with one line for each thing in the basket. When Stripe says the money arrived, Bottle writes the order, marks it paid by card, raises its invoice as paid, and sends the shopper their order email with the tracking link. The office sees a paid order and a paid invoice, the same as when a customer pays a link you sent.

The shop’s own routes, under /storefront/sites/{host}, are for the shop app and need no key. They are not for connecting your own systems.