The products file
A web shop needs the Shop plan. Products live in the site itself, in data/products.json (or the path set as products_path), with prices in cents including VAT:
{
"products": [
{ "id": "mug-blue", "name": "Blue mug", "price": 1495, "image": "/img/mug-blue.jpg" },
{ "id": "shirt", "name": "T-shirt", "price": 2500, "image": "/img/shirt.jpg",
"variants": [
{ "id": "shirt-m", "options": { "size": "M" } },
{ "id": "shirt-xl", "options": { "size": "XL" }, "price": 2700 }
] }
],
"shipping": [
{ "id": "nl", "name": "Shipping in the Netherlands", "price": 495, "countries": ["NL"] },
{ "id": "pickup", "name": "Pick up in the shop", "price": 0, "countries": ["NL"] }
]
}
Spaicely reads only id, name, price, image, active and variants; every other key (descriptions, categories, related products, properties) belongs to the site and is left alone. A size or colour is a variant with its own id, bought by that id, with price or image only where it differs. A large catalog goes in parts: "parts": ["data/products/shirts.json", ...], each {"products": [...]} and at most 20 MB. Each part is indexed once by its content when the version is published, so a catalog of any size never slows down a page or a checkout. A shop sells up to 500 products; variants do not count.
Prices always come from the live products file, never from the page or the request. Publishing a new version is how prices change.
Switching the shop on
shop.setup switches the shop on and says what it still waits for in status: the plan, the billing details, a Stripe account (connect_url is the page where the owner makes one or links the one they have) or a valid products file. The buyer pays on Stripe Checkout, straight to the owner's own Stripe account, with iDEAL, cards and whatever else the owner switched on there. Spaicely keeps 2% plus VAT per payment.
With test_mode: true the whole shop can be tried before Stripe is set up: checkout goes to a Spaicely test page where the tester picks paid, cancelled or failed. Stock, order mails (with TEST in the subject) and thank-you pages work as for real, nothing is paid, no commission is charged, and test orders are gone after seven days. Switch it off to take real orders.
Stock
Spaicely keeps the stock, not the products file. shop.stock sets it per id (set with a quantity and an optional alert_at, add for a delivery or a count); the owner can do the same on the shop page in the dashboard, with a CSV of up to 200,000 lines. An id without stock never runs out.
Starting a checkout holds the items for the 30 minutes the Stripe Checkout stays open; paying takes them off, cancelling, a failed bank payment or an expired checkout gives them back. Each of these happens once per order, whatever the webhooks do. When something has run out, checkout refuses with out_of_stock and available, how many of each id are left. The owner gets a mail when an id comes down to alert_at and when it is sold out.
Pages ask GET https://app.spaicely.com/shop/{shop_id}/items?ids=a,b for the ids they show: name, price, image and what is left of up to 100 ids, plus the shipping options. It is never cached, and an unchanged answer is a 304.
Live stock
A product page or cart that is open sees the count change as soon as someone else starts checking out, pays, cancels, or the owner changes the stock. The page listens on a websocket, wss://app.spaicely.com/cable, with the Action Cable protocol (subprotocol actioncable-v1-json):
→ {"command": "subscribe", "identifier": "{\"channel\":\"ShopStockChannel\",\"shop\":\"SHOP_ID\"}"}
← {"identifier": "...", "message": {"stock": {"shirt-m": 2, "mug-blue": 0}}}
← {"identifier": "...", "message": {"refresh": true}}
Each message gives what is available now for the ids that changed; null means an id no longer keeps stock. After a large import the message is refresh: ask the items endpoint again. Only the site's connected domains and its address may listen, and nothing about visitors goes either way. Missed messages are not sent again, so ask the items endpoint after reconnecting. The cart block does all of this, closes the socket in a tab in the background, and asks every 30 seconds when a socket is not possible.
Holding stock in carts
Normally stock is held only from checkout. With cart_hold in shop.setup a cart holds it from the moment something goes in: low only for ids at or below their alert_at (or 5 without one), or always. It lasts cart_hold_minutes (5 to 60, default 15) from the first hold; then the stock goes back and the cart block takes the item out of the cart and says so. Others see the count drop and rise live. A checkout from the same cart takes over what it holds.
curl -X POST https://app.spaicely.com/shop/SHOP_ID/hold \
-H "Origin: https://yourshop.example" -H "Content-Type: application/json" \
-d '{"cart": "9f2c4e1a7b3d5f60a1b2c3d4e5f60718", "item": "shirt-m", "quantity": 2}'
{"item": "shirt-m", "held": 2, "expires_in": 900, "stock": 1}
cart is a random token the cart makes once and keeps (16 to 64 letters, digits, - or _); send it along as cart in the checkout. quantity 0 gives the hold back. Visitors are anonymous, so the limits are per cart: at most 5 of an id and 20 ids, and a cart whose hold ran out cannot hold that id again for 5 minutes.
The cart block
The building block cart (blocks.get with name cart) is a complete cart: add buttons, also for a size or colour picked in a select, stock and prices on product pages, a cart page with quantities and shipping, live stock, holding, and checkout. Its texts are data- attributes in the language of the page.
<p><span data-price="mug-blue">€14.95</span> <span data-stock="mug-blue"></span></p>
<button type="button" data-cart-add="mug-blue" data-added="Added">Add to cart</button>
<select id="size"><option value="">Choose a size</option><option value="shirt-m">M</option></select>
<button type="button" data-cart-add data-cart-from="#size" data-choose="Choose a size first">Add to cart</button>
<div data-cart data-locale="en-GB" data-held="Held for you until {time}">Loading your cart…</div>
<script src="/cart.js" data-shop="SHOP_ID" data-left="Only {n} left" data-sold-out="Sold out" defer></script>
The whole snippet with every text is in the block itself; see Building blocks.
Checkout without the block
A buy button can be a plain form, which works without JavaScript:
<form action="https://app.spaicely.com/shop/SHOP_ID/checkout" method="post"> <input type="hidden" name="items[0][id]" value="mug-blue"> <input type="hidden" name="items[0][quantity]" value="1"> <input type="hidden" name="shipping" value="nl"> <button type="submit">Buy</button> </form>
A cart of its own posts JSON with fetch and goes to the url in the answer:
const response = await fetch("https://app.spaicely.com/shop/SHOP_ID/checkout", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ items: [{ id: "shirt-m", quantity: 2 }], shipping: "nl" })
});
const answer = await response.json();
if (answer.url) location.href = answer.url;
else alert(answer.message); // out_of_stock comes with "available"
At most 50 lines of 1 to 99 each. Only the site's connected domains and its address may post.
Orders
shop.orders lists the paid orders, the owner gets each one by mail, and the buyer gets an order confirmation from Spaicely on behalf of the shop, in the shop's language: what was bought, the total, the seller (name and address of the owner's account) and the pages set as terms_url and returns_url. A seller has to confirm an order with the terms and how to cancel, so make those pages and set them in shop.setup; notes lists no_terms_url or no_returns_url while they are missing. Replies go to the shop. With confirm_to_buyer: false the shop sends its own confirmation. shop.orders filters on to_ship, shipped, refunded or test, pages back with before and gives one order by order_id (the number the buyer got). shop.orders.ship marks an order as sent, with carrier, tracking_code and tracking_url, and mails the buyer on behalf of the shop; undo: true takes the mark off. The owner does the same, and downloads all orders as CSV, on the shop's order pages in the dashboard. Refunds are made in the owner's own Stripe dashboard: Stripe tells Spaicely, the order shows what went back, and the same share of the 2% commission is returned (all of it when a dispute is lost); a refund after the monthly commission invoice is credited on the next one. invoice_buyers: true makes Stripe send the buyer an invoice in the seller's name, with their VAT number when given (Stripe charges for it), and promotion_codes: true lets checkout take discount codes the owner makes in their Stripe dashboard. Buyer details are deleted 90 days after the order, unpaid orders after 30 days. Pages on delivery, returns and the right of withdrawal belong on the site; the web shop section of the skill site-building lists what they must say.