Buyer ordering and quotes
The buyer app and the customer portal sell to your customers through one set of operations. A customer fills a cart, checks out into a sales order, follows that order, and can ask for a quote instead of ordering at once.
This guide covers the buyer side of ordering and quotes. The same operations serve both surfaces, so what you build against them works in the portal and in a buyer app. For the exact field list of every type, see the API reference. The examples use <...> for values you supply.
1. Who may call
Every operation in this guide needs a customer session. A staff token is refused on all of them, and a customer token is refused on staff operations.
Within a customer session, the user's role decides what they may change:
| Role | Reads | Cart, checkout, quotes, order changes |
|---|---|---|
Owner (CUSTOMER_ADMIN) | Yes | Yes |
Buyer (CUSTOMER_BUYER) | Yes | Yes |
View only (CUSTOMER_VIEWER) | Yes | Refused with FORBIDDEN_ROLE |
Everything is scoped to the signed-in customer. The organization and the customer come from the token, never from an argument, so a customer cannot name another customer's cart, order or quote. An order or quote that belongs to someone else reads as not found.
A customer who orders for one branch sends the branch address ID in the X-Active-Branch-Id header. The server then forces that branch as the delivery address at checkout and ignores any addressId you send. Without the header the customer orders for their own account.
2. Sign in
Customers sign in with a one-time code sent to their email. There is no password.
requestCustomerOtp(input)sends a 6-digit code to an existing, active customer. Passemailand, on a shared host, the organization'ssubdomain.verifyCustomerOtp(input)takesemail,codeandsubdomain, and returns aCustomerAuthResponsewithaccessToken,refreshTokenandexpiresIn(seconds).- Send the access token as a bearer token on every later request. When it expires, call
customerRefreshToken(input)with the refresh token.customerLogoutends the session.
mutation Verify($input: VerifyCustomerOtpInput!) {
verifyCustomerOtp(input: $input) {
accessToken
refreshToken
expiresIn
user { id }
}
}
{ "input": { "email": "<customer email>", "code": "<6-digit code>", "subdomain": "<organization subdomain>" } }
A customer who registered themselves stays pending until your team approves the account. Verifying the code for a pending customer is refused with the pending-approval message instead of returning a session.
3. Find and price products
The storefront reads are plain queries:
customerProducts(filter)lists products with search text, filters, sort and paging.customerCatalogSearch(query, limit, types)is the type-ahead search across products, brands and categories.customerMyVariantPrice(variantId, uomId, quantity)prices one variant for this customer, at the quantity and unit of measure you give.
Prices are resolved on the server for the signed-in customer. The cart and checkout resolve them again, so a price you showed earlier is never trusted.
4. Fill the cart
Each customer has one shared cart, the same on the portal and in the buyer app.
query Cart {
customerCart {
id
revision
itemCount
subtotal
items {
id
quantity
unitPrice
lineTotal
variant { id sku }
}
}
}
| Operation | What it does |
|---|---|
customerCart | Returns the cart with every line priced live. |
customerCartSummary | Returns the item count and total, for a header badge. |
addToCart(input) | Adds a variant, or raises the quantity of an existing line. |
updateCartItem(input) | Sets the quantity of one line. Must be above zero. |
removeCartItem(input) | Removes one line. |
clearCart(input) | Removes every line and keeps the delivery directions. |
Two inputs make cart writes safe to retry, and every cart mutation accepts both:
expectedRevisionis therevisionyou last read. If the cart changed since, the call is refused withCART_REVISION_CONFLICTand the error carries the current cart, so you can show it and retry.operationIdis a key you generate. Sending the sameoperationIdwith the same data again succeeds without doing the work twice. Reusing it with different data is refused withOPERATION_REPLAY_CONFLICT.
addToCart takes productId, variantId, quantity, and optionally uomId, notes and acceptAlternativeIfUnavailable. Adding is refused when the stock is not there (and your organization does not allow backorders): the message says the item is out of stock, or how many are available. A unit of measure the product does not offer is refused too.
customerReorderFromOrder(orderId) copies every line of a past order into the cart. The lines are priced again now, not at the old price.
5. Check out
Checkout turns the cart into a sales order. Call these in order.
Bill the cart
customerCartBill returns the cart priced again on the server, line by line, with tax, discount and delivery. Every line is resolved fresh, never from the price stored in the cart. If a line has no price in your organization's currency, the whole bill is refused with NO_PRICE_IN_CURRENCY.
Choose a delivery option
customerDeliveryOptions(addressId) returns the delivery on offer for the cart to one address. Its mode tells you what to do:
FLAT: the store charges a fixed fee. There are no options to pick and the bill carries the fee.LIVE_QUOTE: the store prices delivery live. Showoptionsthe waychoiceModesays (LIST,SPEEDSorAUTO), then pass the chosen option'sidasdeliveryOptionId.
Pass the same deliveryOptionId to customerCartBill to see the fee. On a live-quote store the bill returns deliveryFee: null and deliverySelectionRequired: true until you do.
Read code before you show a picker. NO_DELIVERY_OPTIONS means delivery is not available to this address. DELIVERY_FREIGHT_QUOTE means the order is too heavy to price live: place it without an option and the store quotes delivery afterwards. When retryable is true, a courier failed for a reason that may clear, so offer a retry.
Place the order
mutation Place($input: PlaceOrderFromCartInput!) {
placeOrderFromCart(input: $input) {
orderId
orderNumber
totalAmount
currency
status
approvalRequest { id requestNumber status totalAmount }
}
}
{
"input": {
"addressId": "<delivery address id>",
"deliveryOptionId": "<option id, when delivery is priced live>",
"paymentMethod": "ON_ACCOUNT",
"poNumber": "<buyer purchase order number>",
"notes": "<notes for the seller>",
"expectedRevision": 0,
"idempotencyKey": "<unique key you generate>"
}
}
Send idempotencyKey on every checkout. If the request times out and you send it again with the same key, you get the order that was already placed, not a second one.
paymentMethod decides how the order starts:
paymentMethod | The order starts as | What happens next |
|---|---|---|
EFT | AWAITING_PAYMENT, prepaid | The customer transfers the money and registers it (section 6). |
GATEWAY | AWAITING_PAYMENT, prepaid | The customer pays online through your payment gateway. |
ON_ACCOUNT | CONFIRMED, on the customer's credit terms | Checked against the customer's credit limit. A refusal is OVER_CREDIT_LIMIT. |
| Omitted | CONFIRMED, on the customer's credit terms | Same as ON_ACCOUNT, without the typed credit error. |
Before it creates anything, checkout checks the whole cart again: stock, the delivery address, the prices and the delivery option. The cart is cleared only after the order exists.
A checkout that waits for approval
If your organization has the approval workflow on, a checkout can be held for an approver instead of becoming an order. This happens when the total is over the user's order limit, over the branch's limit (in branch mode), or, for ON_ACCOUNT, over the customer's credit limit. Owners are exempt from the user and branch limits, but not from the credit limit.
In that case no order exists and the cart is left as it was. The result carries approvalRequest and no orderId. A second checkout of the same cart while a request is pending is refused with DUPLICATE_PENDING_APPROVAL.
An approver decides with customerApproveOrderRequest(requestId) or customerDeclineOrderRequest(requestId, reason). Approving prices the cart again, then creates the order and returns its orderId. It is refused with APPROVAL_NOT_PENDING when the request was already decided, NOT_AN_APPROVER when the caller may not decide it, and OVER_CREDIT_LIMIT when the credit check now fails. customerApprovalHistory lists every request for the customer, newest first, and is open to approvers only.
6. Follow the order
| Operation | What it does |
|---|---|
customerOrders(filter) | Lists the customer's own orders, paged, with status, statuses, search and branchAddressId filters. |
customerOrderDetail(orderId) | Returns one order. |
customerOrderPdf(orderId) | Returns a link to the order PDF. |
customerTrackShipment(shipmentId, refresh) | Follows a shipment of the order. |
customerCancelOrder(orderId, reason) | Cancels the customer's own order. |
customerRegisterEftPayment(input) | Tells the seller about a bank transfer. |
customerBankDetails(orderId) | Returns the bank details to pay into. |
customerCancelOrder uses the same rules as a cancel in the admin console, so an order that has been invoiced or has moved past the point of cancelling is refused. Once an online payment for the order has been captured, the buyer can no longer cancel it and is told to contact support.
For an EFT order, read the bank details, let the customer transfer the money, then register it with customerRegisterEftPayment. Give amount, paymentDate and referenceNumber, and exactly one of orderId or invoiceId. This creates a payment in PENDING status, shown as "Payment under review", until your team verifies the transfer. An amount above what is outstanding is refused with EFT_AMOUNT_EXCEEDS_OUTSTANDING.
7. Request, accept and order from a quote
A customer can ask for a quote instead of ordering. Your organization turns this on in the portal settings (allowQuoteRequests).
- Request.
customerRequestQuoteFromCart(input)turns the current cart into a quote inPENDINGstatus. The server prices it again. The quote is valid for the number of days set in the portal settings (14 when none is set), and the result givesquoteNumberandvalidUntil. Optional input:notes,reference,expectedRevisionandidempotencyKey. - Seller response. Your team reviews and sends the quote. The customer reads it with
customerQuotes(filter),customerQuoteDetail(quoteId)andcustomerQuotePdf(quoteId). - Accept.
customerAcceptQuote(quoteId, expectedVersion, poNumber)accepts a quote that has been sent or opened. The PO number is optional, at most 100 characters, and becomes the order's customer PO number. Accepting an already accepted quote returns it unchanged. - Order.
customerPlaceOrderFromQuote(quoteId, expectedVersion)creates the sales order from an accepted quote. Quoted prices are honoured, not recalculated. The order starts asDRAFT: staff confirm it, and the credit limit and stock checks apply then. A quote can produce one order.
The customer can drop a quote with customerCancelQuote(quoteId, reason).
mutation Accept($quoteId: ID!, $version: Int, $po: String) {
customerAcceptQuote(quoteId: $quoteId, expectedVersion: $version, poNumber: $po) {
id
status
}
}
Pass expectedVersion from the quote you read. If your team changed the quote in the meantime, the call is refused with QUOTE_VERSION_CONFLICT, and the customer should reload it.
8. Common errors
Branch on extensions.code, not on the message text.
| Code | Why | What to do |
|---|---|---|
FORBIDDEN_ROLE | A view-only user tried to change something | Hide write actions for that role |
CART_REVISION_CONFLICT | The cart changed since you read it | Show the cart in the error and retry |
OPERATION_REPLAY_CONFLICT | An operationId was reused with different data | Generate a new operationId |
CART_ITEM_NOT_FOUND | The line is no longer in the cart | Reload the cart |
NO_PRICE_IN_CURRENCY | A line has no price in the organization's currency | Remove the line |
PRODUCT_UNAVAILABLE | A product in the cart can no longer be sold | Remove the line |
ADDRESS_NOT_FOUND, ADDRESS_NOT_ACTIVE | The address is not one this customer may order to | Let the customer pick another |
ACCOUNT_PENDING_APPROVAL, ACCOUNT_INACTIVE | The customer account cannot order yet | Tell the customer to contact the seller |
BRANCH_MODE_REQUIRED, BRANCH_NOT_AVAILABLE | A branch address was used outside branch mode, or the branch is off | Switch branch, or pick another address |
NO_DELIVERY_OPTIONS | Delivery is not available to the address | Pick another address |
OVER_CREDIT_LIMIT | The order would exceed the customer's credit | Pay the outstanding balance, or choose another payment method |
DUPLICATE_PENDING_APPROVAL | The cart already has a request waiting | Wait for the decision |
QUOTE_REQUESTS_DISABLED, CART_EMPTY | The portal does not take quote requests, or the cart is empty | Hide the action, or fill the cart |
QUOTE_NOT_ACCEPTABLE, QUOTE_EXPIRED | The quote is not in a state to accept, or its validity has passed | Ask the seller for a fresh quote |
QUOTE_NOT_ACCEPTED, QUOTE_ALREADY_ORDERED | The quote is not accepted yet, or already has an order | Accept first, or open the existing order |
QUOTE_VERSION_CONFLICT | The quote changed while the customer was reading it | Reload and retry |
PO_NUMBER_TOO_LONG | The PO number is over 100 characters | Shorten it |
Three refusals come back as plain messages without a code: a stock refusal (<product> is out of stock or Only N of <product> available), Cart is empty. at checkout, and an address that is not assigned to the user, whose message starts with ADDRESS_NOT_ASSIGNED:.