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.
Delivery areas
Section titled “Delivery areas”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-areasContent-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
409that names the patch and the area that has it. deliveryOptionIdsare 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.freeOverPencemakes delivery free once the products in the basket, with VAT, reach it. Deposits do not count towards it. Null is never free.minimumOrderPenceis 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.
Stock on a product
Section titled “Stock on a product”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…/stockContent-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:stoptakes the product off sale when none are left,continuekeeps 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.
Trade prices and trade accounts
Section titled “Trade prices and trade accounts”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.
People on a trade account
Section titled “People on a trade account”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.
Product prices and quantity breaks
Section titled “Product prices and quantity breaks”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…/pricesContent-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). Anulltrade price means trade customers pay the Consumer price. - A price list’s
price: nulltakes its price off: its customers pay the Trade price. A price list left out ofpriceListsis left as it is. An unknown list is404, an archived one409. - 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.
Price rules
Section titled “Price rules”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-rulesContent-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.
Price lists (rate sets)
Section titled “Price lists (rate sets)”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
(
422otherwise). 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-setwithdryRun: trueonly counts. WithclearAgreedRates: trueit 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 is404, an archived list409.- A customer is on one list or none.
GET /customers?rateSet=lists a list’s customers, ornonefor customers on no list.PATCH /customers/{id}takesrateSetId.
Deposit refund rules
Section titled “Deposit refund rules”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.
How a shop takes payment
Section titled “How a shop takes payment”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.