Skip to main content

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:

RoleReadsCart, checkout, quotes, order changes
Owner (CUSTOMER_ADMIN)YesYes
Buyer (CUSTOMER_BUYER)YesYes
View only (CUSTOMER_VIEWER)YesRefused 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.

  1. requestCustomerOtp(input) sends a 6-digit code to an existing, active customer. Pass email and, on a shared host, the organization's subdomain.
  2. verifyCustomerOtp(input) takes email, code and subdomain, and returns a CustomerAuthResponse with accessToken, refreshToken and expiresIn (seconds).
  3. Send the access token as a bearer token on every later request. When it expires, call customerRefreshToken(input) with the refresh token. customerLogout ends 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 }
}
}
}
OperationWhat it does
customerCartReturns the cart with every line priced live.
customerCartSummaryReturns 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:

  • expectedRevision is the revision you last read. If the cart changed since, the call is refused with CART_REVISION_CONFLICT and the error carries the current cart, so you can show it and retry.
  • operationId is a key you generate. Sending the same operationId with the same data again succeeds without doing the work twice. Reusing it with different data is refused with OPERATION_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. Show options the way choiceMode says (LIST, SPEEDS or AUTO), then pass the chosen option's id as deliveryOptionId.

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:

paymentMethodThe order starts asWhat happens next
EFTAWAITING_PAYMENT, prepaidThe customer transfers the money and registers it (section 6).
GATEWAYAWAITING_PAYMENT, prepaidThe customer pays online through your payment gateway.
ON_ACCOUNTCONFIRMED, on the customer's credit termsChecked against the customer's credit limit. A refusal is OVER_CREDIT_LIMIT.
OmittedCONFIRMED, on the customer's credit termsSame 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​

OperationWhat 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).

  1. Request. customerRequestQuoteFromCart(input) turns the current cart into a quote in PENDING status. 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 gives quoteNumber and validUntil. Optional input: notes, reference, expectedRevision and idempotencyKey.
  2. Seller response. Your team reviews and sends the quote. The customer reads it with customerQuotes(filter), customerQuoteDetail(quoteId) and customerQuotePdf(quoteId).
  3. 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.
  4. Order. customerPlaceOrderFromQuote(quoteId, expectedVersion) creates the sales order from an accepted quote. Quoted prices are honoured, not recalculated. The order starts as DRAFT: 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.

CodeWhyWhat to do
FORBIDDEN_ROLEA view-only user tried to change somethingHide write actions for that role
CART_REVISION_CONFLICTThe cart changed since you read itShow the cart in the error and retry
OPERATION_REPLAY_CONFLICTAn operationId was reused with different dataGenerate a new operationId
CART_ITEM_NOT_FOUNDThe line is no longer in the cartReload the cart
NO_PRICE_IN_CURRENCYA line has no price in the organization's currencyRemove the line
PRODUCT_UNAVAILABLEA product in the cart can no longer be soldRemove the line
ADDRESS_NOT_FOUND, ADDRESS_NOT_ACTIVEThe address is not one this customer may order toLet the customer pick another
ACCOUNT_PENDING_APPROVAL, ACCOUNT_INACTIVEThe customer account cannot order yetTell the customer to contact the seller
BRANCH_MODE_REQUIRED, BRANCH_NOT_AVAILABLEA branch address was used outside branch mode, or the branch is offSwitch branch, or pick another address
NO_DELIVERY_OPTIONSDelivery is not available to the addressPick another address
OVER_CREDIT_LIMITThe order would exceed the customer's creditPay the outstanding balance, or choose another payment method
DUPLICATE_PENDING_APPROVALThe cart already has a request waitingWait for the decision
QUOTE_REQUESTS_DISABLED, CART_EMPTYThe portal does not take quote requests, or the cart is emptyHide the action, or fill the cart
QUOTE_NOT_ACCEPTABLE, QUOTE_EXPIREDThe quote is not in a state to accept, or its validity has passedAsk the seller for a fresh quote
QUOTE_NOT_ACCEPTED, QUOTE_ALREADY_ORDEREDThe quote is not accepted yet, or already has an orderAccept first, or open the existing order
QUOTE_VERSION_CONFLICTThe quote changed while the customer was reading itReload and retry
PO_NUMBER_TOO_LONGThe PO number is over 100 charactersShorten 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:.