Tote developers
All guides

Shopping

Cart & checkout

The one-store cart, how offers set the price, and every reason checkout can refuse an order.

A shopper has one cart, and a cart holds products from one store. Checkout turns the whole cart into one order at that store.

One store per cart#

POST cart/add takes product_id, store_id and quantity.

Adding a product from a different store than the one already in the cart returns 409:

{
  "status": "conflict",
  "message": "Your cart has items from another store…",
  "current_store": { "ar": "…", "en": "Al Noor Market" },
  "new_store": { "ar": "…", "en": "Fresh Corner" }
}

Ask the user whether to start a new cart. If they agree, call POST cart/clear and add the product again.

Other things to know:

  • Adding a product that is already in the cart adds to its quantity rather than replacing it. To set an exact quantity, use PUT cart/update/{cartItem}.
  • Every cart call returns the whole cart, the same shape as GET cart, so the app can redraw from the response.
  • Placing an order empties the cart.

Before signing in#

The cart works without an account. While nobody is signed in, the app sends no Authorization header and identifies the cart with a UUID it generated itself:

X-Cart-Token: 6f1c2a9e-3b7d-4c11-9a52-0d8e4f7b6a21
  • GET cart with an unknown token, or none, returns an empty cart. The cart is created by the first POST cart/add, which answers 422 (errors.cart_token) when the header is missing or is not a UUID.
  • Editing a line that belongs to another cart answers 403.
  • Sending the header on register or login moves the cart onto the account. If the account already holds a cart for the same store, quantities are added together; if it holds one for another store, the cart the visitor just filled replaces it. An empty anonymous cart changes nothing.
  • Once signed in, the header is ignored: the bearer token alone selects the cart.

See Shopping before signing in for the whole flow.

Offers and prices#

Each cart line stores a unit price that already includes any offer. The cart total is the sum of price × quantity.

A product gets at most one offer, from its store: either an offer on the product itself or an offer on its category. Three kinds exist (see Merchant offers):

Offer Effect on the unit price
Percentage price − price × value / 100
Fixed price − value, never below 0
Buy X get Y Free units spread across the line, so price is the average unit price for that quantity

Because "buy X get Y" depends on the quantity, the unit price of a line can change when its quantity changes. Always show the price and subtotal the server returns; never compute them on the device.

An offer stops applying when it expires, is switched off, or reaches its usage limit. Each order counts as one use of an offer.

Checkout#

POST orders/checkout places the order:

{ "address_id": 12, "notes": "Ring the bell", "delivery_time": "18:00-19:00" }

All fields are optional. address_id must be one of the customer's own addresses. Payment is always by card, so the response also opens the first payment attempt; see Payments.

Checkout needs a customer token (401 without one): a visitor must register or log in first, which carries their cart over.

When checkout says no#

Checkout re-checks the whole cart against the live store inside one transaction. When something no longer holds, it answers 422 with a machine-readable code:

code Meaning What the app should do
cart_empty Nothing to order Go back to the store
store_unavailable The store was deactivated Clear the cart, pick another store
product_unavailable items[] are no longer sold here Remove those lines
prices_changed items[] have a new price (old_price, new_price). The cart is already updated Show the changes, let the user confirm, call checkout again
below_min_order_price current_total is under the store's min_order_price Ask for more items
insufficient_stock items[] with requested and available Lower the quantities
{
  "status": "error",
  "code": "insufficient_stock",
  "message": "…",
  "items": [{ "product_id": 88, "name": { "ar": "…", "en": "Milk 1L" }, "requested": 4, "available": 2 }]
}

Switch on code, not on message: the message is translated.

Stock is only reserved when the order is placed, so two customers can hold the last unit in their carts; the second to check out gets insufficient_stock.