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.
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;
}
}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.
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 }
}
}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": []
}
}
}
}{
"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:
variant.price. Order lines carry the unit price.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.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.
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:
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
}
}
}
}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.
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.
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 } }
}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 } }
}isGuest: true and an email as the identifier.total is the amount to charge. After place nothing can change. If the shopper goes back to edit, start a new cart.place's total. See Payment Gateways.mutation CreateOrder($id: UUID!, $input: OrderFromCartInput!) {
createFromCart(id: $id, input: $input) { id }
}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" }]
}
}{
"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.
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.
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.
A bookable product (a rental, a room, a class) goes into the cart with bookSkuItem, which holds the slot. Three rules matter for checkout:
confirmCartBooking after place, and again once createFromCart has given the cart its order id. Only the second confirmation links the reservation to the order.Availability, holds and confirmation are covered in full on Booking APIs.