Skip to main content
A shopper goes from browsing to a paid order. Steps 1–6 run with the publishable client in the browser (catalog, cart, shipping, an optional stock reservation); the order and payment in steps 7–8 run with the secret client on your server. Step 7 builds the same order three ways — base, with a promotion, with a promotion and a gift card — alongside the two rejections that guard the totals. Workflow Examples has the client setup.

1. Browsing the catalog

A plain list, then the narrowing and trimming a real grid does.
Response
A grid needs a few columns, not the full product. fields fetches only those, keeping the response small:
fields accepts a fixed allowlist of column names (e.g. id, name, price, sale_price, media, thumbnail_url, category_name, stock) — not arbitrary keys. An unknown name returns 400 invalid_request (“Invalid field names: …”). id and product_id are interchangeable.
Lists are cursor-paged: next_cursor goes back as cursor until has_more is false.
A category page filters on categoryId, and combines with subcategoryId for hierarchy:
Build navigation with storefront.taxonomy.listCategories(), or wire a search box with storefront.search.searchProducts({ q: 'wireless headphones' }). All read-only, all publishable-key — see Search & discovery.

2. Adding to an anonymous cart

A shopper who isn’t signed in gets a session cart keyed by an X-Session-Id you generate and persist (a UUID in a cookie). No customer token needed yet.
Response
The line item’s id (09695de2-…) is the cart-item id, not the product or variant id, and it is the handle for updating or removing that line. It is distinct from variant_id: the same variant added twice remains one line, mutated by its id.

2b. Managing the cart — view, update, remove, clear

The rest of the cart lifecycle runs on the same identity as the add: xSessionId for an anonymous cart, or xAuthToken once the shopper signs in. Reading the current cart, for a cart drawer or a badge count:
Changing a line’s quantity addresses the line by its cart-item id, not the variant:
updateCartItem with quantity: 0 is equivalent to removeCartItem, so a quantity stepper decremented to zero needs no separate branch.
Removing a single line explicitly, for a ”✕” on the row:
Emptying the whole cart, for a “Clear cart” action or a reset after an abandoned checkout:
The steps that follow assume a populated cart, so re-add an item after clearing. The wishlist mirrors this shape — addToWishlist / removeFromWishlist / moveWishlistToCart, the last of which hands a saved item back into the cart flow above.

3. Signing the shopper in

Checkout needs a session for the shopper. Registration and login are sk-only endpoints, so they run on your server with the secret client.
Response
Passwordless? Use server.authentication.sendMagicLink(...) or the OTP pair (sendMagicLinkverifyOtp) — both return the same { user, customer, session } envelope.

4. Merging the anonymous cart into the customer

Everything the shopper added before signing in carries onto their account.

5. Quoting shipping

Delivery is quoted for the shopper’s address before the order is totalled. Read-only, publishable key. The amount returned here is the one the order endpoint validates against, so it is the amount the total is built from.
Response
When you place the order, pass shipping_latitude + shipping_longitude so the server re-resolves this fee and validates shipping_amount against it.

With multi-carrier shipping (Shippo)

When the store has multi-carrier shipping connected, a destination address_to plus a parcel returns live carrier rates for the shopper to choose from. The response then carries rates[] and rate_source: "shippo".
Response
The shopper picks from rates[]; shipping_amount becomes that rate’s amount, and its rate_id travels into the order as shippo_rate_id, which the server validates the amount against.

6. Reserving stock before checkout (optional)

Where checkout takes a while — an async payment flow, a multi-page wizard — a reservation holds the stock so the shopper cannot be oversold partway through. Each hold expires by itself after ttl_seconds. The call originates in the browser, so it takes a publishable key, and the variant_id comes forward from the cart.
Response
If the requested quantity isn’t available, nothing is reserved and the call returns 409. Reserving is optional — skip it for a fast synchronous checkout; the order itself still validates stock at creation.

7. Placing the order (server, signed)

Order creation is a secret-key call, HMAC-signed and carrying an idempotency key. The idempotency key is what makes a retried checkout safe: the same key returns the original order rather than a duplicate. HMAC Signing has the helper. The three orders below are the same cart — the 2 Galaxy Watches from step 2 — priced three ways: base, with a promotion, then with a promotion and a gift card.
Order items take variant_id (the exact SKU the shopper picked) or product_id (which resolves the product’s default variant). When both are sent, variant_id wins. On a multi-variant product, dropping to product_id at checkout can order a different variant than the one in the cart, so the variant_id added to the cart is the one to carry into the order line. See Products and variants.

7a. The base order

Response
post_processing_warnings is present on every create, including a 201. It is empty here, but a paid order can succeed while a downstream step — gift card redemption, stock reduction — fails, and those failures surface in this array rather than as an HTTP error. 7d shows a non-empty one.

7b. The same order with a promotion applied

The cart subtotal of 598clearstheSpringSale15598 clears the Spring Sale — 15% off (min 50) promotion (df290d89-0400-4b2c-aa56-11fcc486150c). storefront.promotions.calculatePromotionDiscount(...) quotes the discount; the order then carries it as discount_amount plus a promotion_usages entry, which is what tracks the usage. 15% of 598is598 is 89.70, so the total becomes subtotal − discount + shipping = 598 − 89.70 + 5.99 = $514.29.
Response

7c. The same order with a gift card as well

The shopper also redeems gift card GTSA-1B2C-3D4E-5F6G (100balance)for100 balance) for 50 against this order, as a gift_card_redemption: { code, amount } block with the $50 folded into discount_amount.
A gift card reduces the total as part of discount_amount. The server validates total_amount === subtotal + tax + shipping − discount_amount, and the discount it accepts is the sum of every real entitlement: promotions plus the gift card’s redeemable amount. With subtotal 598, shipping 5.99, the 89.70promotionanda89.70 promotion and a 50 gift card, discount_amount is 139.70andtotalamountis464.29(598+5.99139.70).Thegiftcardbalanceisdrawndownbytheredeemedamountoncetheorderiscreated.Agiftcardsentwithadiscountamountthatomitsitleavingthetotalat139.70 and `total_amount` is `464.29` (598 + 5.99 − 139.70). The gift card balance is drawn down by the redeemed amount once the order is created. A gift card sent with a `discount_amount` that omits it — leaving the total at 514.29 — reduces nothing, and over-claiming a card beyond its balance is rejected as discount_invalid (7d).
Response
Reduced to its simplest case: a 50giftcardona50 gift card on a 250 cart is discount_amount: 50, total_amount: 200, and gift_card_redemption: { code, amount: 50 }. The shopper pays 200andthecardsbalancedropsby200 and the card's balance drops by 50. (Verified live: a 50redemptionona50 redemption on a 239.98 cart plus $5.99 shipping produced total_amount: 195.97.)

7d. The two rejections

The server is the price authority. Order creation recomputes every item price from the live catalog and validates the claimed discount_amount against the promotions and gift card named on the order, so amounts computed client-side carry no weight. The two guards below are what a mismatch meets.
A total_amount that does not equal subtotal + tax + shipping − discount_amount is rejected as 400 price_mismatch before the order is created — here the gift card is in discount_amount but the total was left unchanged:
Response (400)
Consistent total math is not on its own sufficient. The server recomputes the maximum discount the claimed promotions and gift card actually grant and rejects a discount_amount above it as 400 discount_invalid — a discount with no promotion or gift-card balance behind it has a maximum of 0, which is why neither an invented discount nor a redemption beyond a card’s balance lowers the price:
Response (400)
An unrecognized gift card behaves differently again: a paid order redeeming one is not rolled back. The order is created, returns 201, and the failure arrives in post_processing_warnings and stamped onto order.notes.
Response (201)
Stock reduction fails the same way: a paid order short on inventory still returns 201, with a { "stage": "stock_reduction", ... } warning and a [STOCK_REDUCTION_PARTIAL] note. A non-empty post_processing_warnings means the order was placed and a person needs to follow up on it.

8. Taking payment (server, signed)

Payment initialization is also sk + HMAC + idempotency. The shopper is redirected to the returned checkout_url, and the order is finalized from the provider’s webhook. The amount charged is the order’s own total_amount, which already reflects any gift card folded into its discount_amount.
Response
When the provider confirms payment, the webhook handler patches the order to paid (server.orders.updateOrder(...)). Galactic Core then reduces stock, books the sale, and updates the customer’s metrics, so none of that is orchestrated by the integration. Webhooks & automation covers the handler.

9. Fulfilment and tracking

Once the order is paid, the merchant buys the carrier label from their admin. Buying a label charges the merchant’s carrier account, marks the order shipped, stamps the order’s tracking_number, and fires an order.shipped webhook — it is a fulfilment action, not a storefront SDK call. The carrier’s own tracking page is the shortest route to shopper-visible tracking. When the label is bought, the carrier tracking URL is saved on the order at shipping_metadata.shippo.tracking_url, and rendering it as a link needs no API call:
For a status rendered inside the storefront instead, trackShipment returns the live status and history to build a timeline from:
The order.shipped webhook is the moment either of the above becomes available to show.

What’s next