Crystallize logo

Cart, checkout and orders on the Shop API

The cart, the price a customer pays, placing the cart, taking payment and creating the order all run on the Shop API, from your server. This page shows the flow as a storefront runs it, with the price context for markets and B2B customers, quotes, bookings and the order confirmation.

This page is the storefront's view of the flow. The Checkout Flow Tutorial explains each mutation step by step, and Orders is the order reference.

Call the Shop API from your server

The Shop API accepts browser calls only from Crystallize origins and localhost. A storefront that calls it from the browser works in development and fails on its own domain. Call it from server actions or API routes, with a JWT cached on the server:

const SHOP = `https://shop-api.crystallize.com/${process.env.CRYSTALLIZE_TENANT_IDENTIFIER}`;
let jwt: { token: string; expires: number } | null = null;

async function token() {
  if (jwt && jwt.expires > Date.now() + 60_000) return jwt.token;
  const res = await fetch(`${SHOP}/auth/token`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-crystallize-access-token-id': process.env.CRYSTALLIZE_ACCESS_TOKEN_ID!,
      'x-crystallize-access-token-secret': process.env.CRYSTALLIZE_ACCESS_TOKEN_SECRET!,
    },
    body: JSON.stringify({ scopes: ['cart', 'order'], expiresIn: 18000 }),
  });
  const { token } = await res.json();
  jwt = { token, expires: Date.now() + 18000 * 1000 };
  return token;
}

export async function shop<T>(query: string, variables = {}, endpoint: 'cart' | 'order' = 'cart'): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${SHOP}/${endpoint}`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${await token()}` },
      body: JSON.stringify({ query, variables }),
      cache: 'no-store',
    });
    // A rejected token answers 401 with { success: false } and no errors: get a new one once.
    if (res.status === 401 && attempt === 0) { jwt = null; continue; }
    const json = await res.json();
    if (json.errors) throw new Error(json.errors.map((e: { message: string }) => e.message).join('\n'));
    return json.data as T;
  }
}

The browser keeps only the cart id, in an httpOnly cookie.

The cart

Hydrate with the whole cart

hydrate creates a cart, or replaces the contents of an existing one when you pass its id. Items, customer, metadata and context are all taken from the input, so send the complete cart every time. Anything left out is dropped, including the customer and the price context.

mutation Hydrate($input: CartInput!) {
  hydrate(input: $input) {
    id
    state
    isStale
    items {
      lineId
      name
      quantity
      variant { sku product { name path } price { net gross } }
      price { net gross taxAmount }
    }
    total { net gross taxAmount }
  }
}
{
  "input": {
    "id": "CART_ID",
    "items": [{ "sku": "palissade-cone-table-olive-square", "quantity": 2 }],
    "customer": { "identifier": "buyer@example.com", "isGuest": false },
    "context": {
      "language": "en",
      "price": {
        "currency": "EUR",
        "selectedVariantIdentifier": "default",
        "pricesHaveTaxesIncludedInCrystallize": false,
        "taxRate": 0.21,
        "decimals": 4,
        "markets": []
      }
    }
  }
}

Leave id out to create a new cart. Things that catch storefronts out:

  • `price` on a cart line is the line total, quantity included. The unit price is on variant.price. Order lines carry the unit price.
  • Use `decimals: 4` and round to cents only for display. With the default of 0, line and tax amounts are rounded to whole units.
  • `taxRate` is a fraction (0.25), not a percentage.
  • `currency` is a label. It converts nothing, so pass the currency of the price variant you select.
  • An unknown or unpublished SKU is dropped without an error. Compare the lines you get back with the ones you sent.
  • Don't write to one cart in parallel. The last write wins. Batch quick changes into one hydrate and work from the cart it returns.

Prices for the market and the customer

The price context decides what the shopper pays:

  • selectedVariantIdentifier picks the price variant for the market (nok, sek, default). fallbackVariantIdentifiers are tried when it has no price, and compareAtVariantIdentifier gives the "before" price for discounts.
  • markets applies the price lists of those markets.
  • The customer on the cart applies price lists aimed at that customer, including lists aimed at a parent organisation. For a B2B buyer, put the organisation's identifier on the cart and the person in cart metadata, so orders belong to the company.
  • voucherCodes applies vouchers for promotions.

Re-hydrate a cart when it is stale (isStale), when it was built in another language, or when the shopper signs in or out, so prices follow the new customer.

Contract prices on product pages

Discovery has no idea who is asking, so it can't show a contract price. For a signed-in buyer, resolve it on the server and cache it per organisation:

  • Through the Shop API: hydrate a throw-away cart with the customer and the SKUs on screen, and read the line prices. The cart then agrees with the product page by construction.
  • Through the Catalogue API: priceFor with the customer's identifier resolves the price list for display.
query ContractPrice($skus: [String!]!, $customers: [String!]) {
  productVariants(skus: $skus, language: "en") {
    sku
    priceVariant(identifier: "default") {
      price
      priceFor(count: 1, customerIdentifiers: $customers) {
        price
        identifier
        modifier
        modifierType
      }
    }
  }
}

productVariants(skus:) takes at most 150 SKUs, and priceFor is slow enough that it must be cached. Show prices excluding VAT to trade buyers and including VAT to consumers, and say which on the page.

warning

Don't put contract prices in a market

A market created for one customer makes that customer's prices readable by anyone through an open Discovery API. Use markets for public price lists, and resolve customer-specific lists on the server.

Checkout

The cart becomes an order in four steps. Each is a separate call, in this order:

mutation SetCustomer($id: UUID!, $customer: CustomerInput!) {
  setCustomer(id: $id, input: $customer) { id }
}

mutation SetMeta($id: UUID!, $meta: [KeyValueInput!]!) {
  setMeta(id: $id, meta: $meta, merge: true) { id }
}

mutation Place($id: UUID!) {
  place(id: $id) { id state total { gross currency } }
}
  1. `setCustomer` with the shopper's details. A guest has isGuest: true and an email as the identifier.
  2. `setMeta` with anything the order needs to carry: market, a purchase order reference, the person who ordered for a company. Cart metadata is copied to the order.
  3. `place` locks the cart. It hydrates one last time, and its total is the amount to charge. After place nothing can change. If the shopper goes back to edit, start a new cart.
  4. Take payment with the provider, on your server, for place's total. See Payment Gateways.
  5. Create the order when the payment is confirmed, usually in the provider's webhook:
mutation CreateOrder($id: UUID!, $input: OrderFromCartInput!) {
  createFromCart(id: $id, input: $input) { id }
}
{
  "id": "CART_ID",
  "input": {
    "type": "standard",
    "paymentStatus": "paid",
    "payments": [{ "provider": "stripe", "method": "card", "amount": 1452.0, "transactionId": "pi_123" }],
    "pipelines": [{ "identifier": "PIPELINE_ID", "stage": "STAGE_ID" }]
  }
}

Pass pipelines, payment status and payments in the createFromCart input rather than in follow-up calls. In pipelines, identifier and stage are the ids of the pipeline and the stage, not their names. Read them once at build time and keep them in your configuration. The order's id is the cart's id, and the cart moves to ordered by itself, so no further cart call is needed.

The confirmation page

Show the order that createFromCart returned. Reading it back with order(id) right away can answer "not found" for a moment, so retry before you give up. The shopper's order list, orders(customerIdentifier), can take from seconds to minutes to show a new order. Say so on the confirmation page, and never use the list to check whether an order exists.

Quotes

A B2B quote request is an order of type quote, created from the cart without payment and placed in a quotes pipeline. When sales has priced it and the buyer accepts, create the real order with create, copying the quoted lines and prices and linking the quote in relatedOrderIds. Then move the quote to its accepted stage with addToStage. Do all of this on the Shop API, so the order list stays consistent.

Bookings

A bookable product (a rental, a room, a class) goes into the cart with bookSkuItem, which holds the slot. Three rules matter for checkout:

  • Hydrate keeps a booking only if the line comes back with its `lineId`, metadata and booking. Treat booked lines as pass-through when you re-hydrate.
  • Confirm the booking twice: confirmCartBooking after place, and again once createFromCart has given the cart its order id. Only the second confirmation links the reservation to the order.
  • Removing a booked line inside the cancellation window is refused. Re-hydrate the cart without the line instead, which releases the hold.

Availability, holds and confirmation are covered in full on Booking APIs.

See it live

  • Rentals with bookings: the Tools Universe rental section books one machine at a depot into the cart, with availability per day, and confirms the reservation after the order.
  • B2B on invoice: Food Universe is a B2B store with contract prices, a credit limit checked at checkout, and orders placed on invoice. Its build notes are on superfast.shop/demos/food-universe.
  • Quotes and trade accounts: Tools Universe has card checkout for consumers, and invoice and quote requests for signed-in trade buyers.