This recording has not been published on YouTube yet. The written guide is available below.
Written guide
Companion source code
Get the course source code on GitHub. The folder includes the supplied examples and setup instructions. Keep credentials in your own environment; use the maintained API reference when adapting recorded request examples.
Paid-commerce revision: actual Stripe and PayPal sandbox purchases, cancellation/resumption, option pricing, coupons, saved baskets, paid customer/staff readbacks, and an EasyPost test label. This edition replaces the rejected unpaid-only draft. No live charge, live postage or delivered customer message is claimed. Gift-card issuance/redemption remains a separate pending demonstration because creation of redeemable value was not authorized; this is not counted as completed coverage.
Watch the current narrated revision · Download runnable clients and observed JSON · Course catalogue
What connects the interface to AppEngine
Your website owns its presentation and local shopping selection. AppEngine owns the saved product/configuration records and exposes the operations that calculate a quote, persist a customer basket and create the business order. Stripe and PayPal hold separate payment objects. Successful checkout connects those identities and verifies the actual amount, currency, customer and tender after the order is saved.
A public product read is different from customer cart/order access. Staff setup uses a third identity. The included Next.js shop signs in an ordinary customer, keeps its bearer in an HttpOnly cookie and mounts the checkout companion under the same website; checkout validates that same cookie with the customer profile. The shopper carries their selected basket and identity across without a second sign-in. An application credential is a different server identity, documented in the original examples; it is not a substitute for the customer's ownership context.

Follow one basket through an actual purchase
Prepare a product and its delivery rule, then sign in through the example's Sign in page. Open the print, choose two, and inspect the server quote: subtotal48, delivery6.50, total54.50USD. Continue to checkout under the same website. Enter the destination, calculate, save and read back. The stored cart ID, lines, option values, destination and amount are checked again before payment.

Stripe's successful sandbox intent is attached to order HS851V342. PayPal's completed sandbox order 2P174672ES840272J, with completed capture 42H76939SE7968341, is attached to AppEngine order C3V466AVY. Both orders are paid54.50USD. These are evidence identifiers from the training organization, not values to copy into a new checkout. Create your own attempt and retain its returned identifiers.


In the PayPal procedure, closing the approval window before capture shows cancellation while preserving the basket and provider order. Resuming reuses that order. The recorded approval uses PayPal's actual sandbox page and returns a completed result. A network loss after capture is a different recovery branch: the starter stores a durable capture lock and reconciles existing customer orders by the retained provider reference, without blindly charging again. The download contains21 focused checkout/recovery tests, including mismatched amount/currency/customer and lost checkout response; those mocked failures supplement the genuine provider purchases.
Inspect the business result
Open Studio → Storefront → Order and locate the same order number. Inspect Items, Pricing and Payments. The customer read uses the order's number, not its internal sk. The Stripe readback has two units,54.50 amount, a matching payment reference and zero remaining balance. Current recorded order lines can lack a saved display name/image; match the actual SKU rather than inventing a name in observed JSON.


The owner can read HS851V342; a separate customer receives HTTP200 with JSONnull for that same number. This establishes the specific tested customer route, not blanket access control for every endpoint. Keep repository/staff operations outside the customer proxy.
Chapter map
- AppEngine Headless Storefront Development
- The shop, the basket and the payment
- Start with the organization and existing clients
- Prepare the product and its delivery rule
- Read products and preserve their identities
- One customer session across shop and checkout
- Choose a delivery rule for the destination
- Calculate the complete basket
- Save and read back the customer cart
- Configure Stripe and PayPal sandbox gateways
- Read browser-safe gateway configuration
- Create an intent for the saved basket
- Complete the Stripe customer purchase
- Verify payment and order together
- PayPal order, approval and capture
- Cancel and resume the same PayPal checkout
- Approve the sandbox purchase and read the paid order
- Recover an uncertain payment without charging twice
- Inspect the same paid order in Studio
- Verify the second customer boundary
- Select a product option and apply a coupon
- Preserve variants and discounts in the API request
- Quote and create an EasyPost test label
- Attach the correct label to the paid order
- Location inventory and safe stock retry
- Read the saved label and tracking result
- Run the clients and choose the transport
- Your working headless commerce integration
Prerequisites you can actually follow
- AppEngine Introduction: obtain your organization's AppEngine endpoint and organization ID.
- API Foundations: ordinary customer account, password setup, sign-in and verification. Prepare two controlled customers when reproducing the ownership check.
- Database & Repository APIs, 1:02 staff sign-in: obtain the staff bearer for product/provider/shipping preparation. A customer token does not replace staff authorization.
- Canonical illustrated lesson, course catalogue.
Use https://studio.appmint.io for the management UI and https://docs.appmint.io for platform documentation. Studio is not automatically your API endpoint. Follow the endpoint supplied for your organization/environment.
The course's training email configuration is inactive with outgoing email disabled. Creating an order can still reduce stock and invoke configured business processing; prepare that policy deliberately. Existing demonstration provider configurations were reused only after verifying Stripe test keys/livemode=false and PayPal Sandbox. No merchant secrets are included in this archive.
Prepare a product and delivery rule
In Studio → Storefront → Product, create a uniquely named product with SKU, Available status, price24 and an image intended for visitors. Set stock if using tracked inventory. In Shipping → Settings → Shipping Methods, create an active flat method with currencyUSD, countriesUS, rate6.50 and a stable name such as headless-1010-shipping. On the product, Shipping → Shipping Configuration references the configuration name, not its record sk. Save and reopen both records.
json/product.json and json/shipping-config.json contain complete optional preparation bodies. Replace the image URL and names/SKU consistently before running this deliberate one-time staff operation:
export APPENGINE_ENDPOINT='https://YOUR_APPENGINE_HOST'
export ORG_ID='YOUR_ORGANIZATION_ID'
export STAFF_TOKEN='YOUR_PRIVATE_STAFF_BEARER_WITHOUT_PREFIX'
node prepare.mjs --createThe initializer creates shipping first, then product; inspect any partial result before retrying. It does not roll back the first write. Never expose this staff route through the customer-facing proxy.
Inspect GET /storefront/product/{sku} and the actual visitor catalog. A status label alone does not establish publication visibility. The catalog page in this course filters to the training SKU in nextjs-store/app/page.tsx; replace that filter or remove it deliberately when using your own catalog. The product route itself can display any available SKU.


Run the integrated website and checkout
Requires Node20+ and npm for Next.js. The companion uses only Node built-in modules. Both processes use the same endpoint and organization. Start checkout first in one terminal:
cd checkout-lab
cp .env.example .env.local
# Set APPENGINE_ENDPOINT and ORG_ID.
# PORT=4341; PUBLIC_ORIGIN=http://127.0.0.1:4100
node --env-file=.env.local server.mjsIn another terminal:
cd nextjs-store
cp .env.example .env.local
# Set the SAME APPENGINE_ENDPOINT and ORG_ID.
# CHECKOUT_SERVICE=http://127.0.0.1:4341
npm install
npm run devOpen http://127.0.0.1:4100 for this local exercise. The package's Next command uses4100. Keep the exact browser origin and checkout PUBLIC_ORIGIN equal; changing only a port or hostname breaks the deliberate origin check. These are local execution instructions, not a claimed public deployment or a substitute for your real AppEngine address. The recording omits browser chrome.
Sign in through Sign in, then open a product, select its options if present, and add it to the cart. Change quantity and inspect returned pricing. Continue to secure checkout carries the selected SKU/quantity/options/coupon to /checkout. Next explicitly rewrites that page and /api/checkout/* to the private companion. It does not expose arbitrary AppEngine paths.
Next's HttpOnly appmint_token cookie remains inaccessible to browser JavaScript. The checkout server validates that customer token via /client-data/profile and retains its own opaque session. The shopper does not sign in a second time. The verified profile supplies the email used for the saved cart. Staff repository access is never used for these shopper operations.
The original Next and Remix examples also support server-only configured application credentials. That is an alternative transport, not a reason to create a privileged System account. The filmed route uses public catalog operations and the ordinary customer's bearer for private operations. The original examples and their contracts are linked at Next storefront and Remix storefront. Remix is included as the existing loader/resource-route alternative; a newly executed paid Remix client is not claimed.
Price, save and restore the customer basket
POST /shipping/options receives items:[{sku,quantity}] and toAddress. Inspect served and the returned delivery offer. A rejected destination must not become a fabricated free-shipping offer.
POST /storefront/pricing/calculate-cart receives productItems, rentalItems, shippingAddress and optional couponCode. Selected product options stay on their line as options:[{name,value}]. Send SKU/quantity/selection, without treating browser-stored prices as authoritative. Display returned subtotal, discount, delivery, tax and total; do not subtract the discount a second time. Tax0 in this fixture is its configuration, not a general tax rule.
Calculation can allocate an empty cart record. Persist the accepted quote with POST /storefront/cart/update/{cartId} and {datatype:'sf_cart',data:{...quote,shippingAddress,email}}. Then GET /storefront/cart/mine under the same customer session must return that cart identity, selected options, destination and total. Restore my cart reads this server basket, distinct from the browser's shopping selection. Changing the basket requires a new quote before payment.

Configure and complete Stripe sandbox payment
In Studio → Gateway Manager → Integration Builder → Payment → StripeProvider, supply Name, intended priority, active status and matching Publishable Key and Secret Key from Stripe's API keys page. Use test/sandbox keys. The secret remains in AppEngine configuration; only the publishable key and individual intent's client secret belong in the browser payment flow.
Save the authorized configuration and run Test configuration. Our actual saved test returned success and livemode=false. Connection authentication is not payment proof. GET /storefront/payment-gateways discovers sanitized configuration; omit amount/currency when merely discovering gateways.
In the shop choose two24-dollar prints, continue to checkout, enter the eligible address, and Calculate, save and read back. The saved total is54.50. Pay by test card checks the current saved basket and requests /storefront/stripe/intent with cartId, amount54.50, currencyusd and verified customer email. It requires pk_test_ and intent livemode=false. Stripe reports5450minor units for54.50USD.
Real Stripe Elements collects the provider test card. In our sandbox take, card4242424242424242, a future expiry and test CVC completed the payment. Use Stripe's testing documentation for current test cases; never enter a real card into this teaching run. After successful confirmation, the server sends the existing cart and payment reference to /storefront/checkout-cart, then reads the resulting order by order number.
The filmed orderHS851V342 ispaid54.50USD and records intentpi_3UP9WACH9tYO5hDg15wEPvip. Independent staff-side provider verification returned succeeded, livemodefalse, amount5450 and amount_received5450. Studio Payments shows gross collected54.50 and uncollected balance0. No receipt delivery was asserted.

Configure and complete PayPal sandbox payment
Use PayPal Developer → Apps and Credentials with Sandbox selected. Use the intended sandbox business application's client ID and secret. In Studio's PayPalProvider configuration, enter Client Id, App Secret, enable Sandbox, set the intended active status/priority and save. Test configuration authenticated the existing sandbox credentials. A connection test does not capture a purchase.
The checkout loads PayPal's JavaScript SDK with the public client ID and currencyUSD. Its actual actions.order.create body includes intent:'CAPTURE', the saved cart ID as purchase-unit reference, and amount:{currency_code:'USD',value:'54.50'}. PayPal uses major currency units here, unlike Stripe's returned minor units. The companion binds the returned provider order ID to the attempt; it cannot be replaced by another browser-submitted ID.
The filmed PayPal route browses the same shop, saves the basket, opens PayPal and deliberately closes approval. The cancellation message retains the basket and attempt. Resuming reuses the existing provider order. In the provider's sandbox guest form, test payment details complete Pay now as guest. We did not create a new PayPal account. The recorded sandbox used PayPal's published Visa test number 4005519200000004, a future expiry and CVV 123, with fictional controlled contact/billing details. Obtain supported cards from PayPal's official sandbox card-testing page; never use a real payment card. If your sandbox offers account login instead of guest payment, use a sandbox personal buyer account, distinct from the merchant's sandbox business account: Developer Dashboard → Testing Tools → Sandbox Accounts → personal account → View/Edit Account. That is an alternative approval route; the recorded take demonstrates guest payment, not a newly executed personal-account login.
Before capture the companion obtains the current provider order and persists a capture lock. If alreadyCOMPLETED it does not capture again. AppEngine verification expects the top-level PayPal order ID, not the nested capture ID. The filmed customer orderC3V466AVY ispaid54.50 and references PayPal2P174672ES840272J. Independent readback returned that orderCOMPLETED plus capture42H76939SE7968341COMPLETED for54.50USD.
Recovery and verification
Run all 21 local mocked checkout/recovery checks with node --test checkout-lab/server.test.mjs checkout-lab/paypal-companion-contract.test.mjs from the extracted package root. They use local test servers and injected mock providers; they do not charge a card or call AppEngine.
The finalizer requires the expected customer, amount and case-insensitive currency, paid status and matching recorded tender reference. An order sk alone is insufficient. A cancellation before capture is not the same as losing the response after capture.
Check existing payment/order performs read-only reconciliation by searching the signed-in customer's orders for the stored provider reference and then verifying the exact saved record. No match leaves the attempt locked; it does not automatically recapture or create another order. A merchant must inspect the provider and business record before deciding the next action. The package includes21 meaningful mocked checkout/recovery tests, supplementary to actual provider purchases. A rejected checkout does not establish that a refund succeeded; inspect a real refund before making that claim.
GET /client-data/orders/{number} expects the customer order number, not the database sk. The recorded Stripe order is available to its owner; the same request as another controlled customer returned HTTP200 and JSONnull. This tests this particular route, not all platform authorization.
The optional Place an unpaid order action remains explicit. It sends no payment reference and creates a real order awaiting payment; it is not used as payment proof. It can still affect stock and configured business processing.

Product options and coupons
Prepare your own option product and coupon
The earlier prepare.mjs creates only the simple print. The optional product/coupon exercise needs these two additional saved records. The exact demonstrated request bodies are supplied as guarded staff descriptors, with record-specific values replaced by explicit placeholders:
- Edit
json/operations/prepare-option-product.json. Replace everyYOUR_marker: choose a unique product name, slug, base SKU and Large variation SKU. Setshipping.configto the name of your already saved flat delivery rule. Retain the two size values, prices, variation mapping and stock fields. The product body is otherwise the demonstrated preparation contract. - Edit
json/operations/prepare-option-coupon.json. Choose a unique name and coupon code; itsproductsarray must contain exactly your new base SKU. It creates an active, manually entered 10-percent product discount, not a gift card. - Read both bodies and verify the endpoint/organization and controlled training context. Use the staff bearer obtained in the linked staff sign-in lesson. Run each create once:
export STAFF_TOKEN='YOUR_PRIVATE_STAFF_BEARER_WITHOUT_PREFIX'
CONFIRM_TEST_OPERATION=yes node operation.mjs json/operations/prepare-option-product.json > option-product-created.json
CONFIRM_TEST_OPERATION=yes node operation.mjs json/operations/prepare-option-coupon.json > option-coupon-created.jsonThese are persistent writes. If either result is uncertain, inspect the saved SKU/code before retrying; the runner does not deduplicate creation or roll back the other record. The unresolved placeholders intentionally prevent execution until replaced.
- With the customer identity, run
node client.mjs product YOUR_CHOSEN_BASE_SKUand inspect the saved attributes/variations and delivery reference. Open/product/YOUR_CHOSEN_BASE_SKUin the included shop; the default catalog filter contains only the original print unless you deliberately extend it. - Replace the base SKU in
json/operations/option-quote.jsonandcoupon-quote.jsonwith your chosen SKU, and the latter's couponCode with your chosen code. SetCUSTOMER_TOKENprivately for the generic runner. Runnode operation.mjs json/operations/option-quote.json, then the coupon descriptor. With the same US destination and flat rule, Large should return 25 plus 6.50 delivery; the coupon should return discount 2.50 and total 29. Confirm the returned variation SKU is your saved Large variation. - In the shop choose Large, apply/remove your coupon, try an unknown code, and save the accepted basket through checkout. Read
/storefront/cart/mineto confirm the option, discount and amount together. Use your own returned cart ID; the evidence archive's IDs are not your configuration.
The separately prepared option product uses baseSKUHEADLESS-OPTIONS-1010. Its size attribute supports small and large; the saved Large variationSKUHEADLESS-OPTIONS-LARGE-1010 has an absolute price25. Small is20. An unmatched variation can instead use saved attribute price adjustments, so inspect the actual returned price and variation identity rather than assuming every variant is a surcharge.
The browser sends the baseSKU plus options:[{name:'size',value:'large'}]. Cart keys include a normalized option combination, so Small and Large do not collapse into one line. Quantity changes affect the selected line. The checkout validates selections against saved attributes and compares the option-bearing saved lines before payment.
CouponHCOPTION10 is scoped to this exercise. Large plus6.50delivery totals31.50; the accepted2.50discount yields29. Remove the code to obtain31.50; an invalid code returns valid:false, reason:'not_found'. Do not save an invalid quote. The actual saved Nina cart retained Large,2.50discount and29total. JSON requests and observed responses are included under json/operations and observed-json/payment-revision.

EasyPost test rates, label and tracking
Create an EasyPostProvider configuration using the provider account's test API key, not a production key. Enable Shipping (and Logistics if needed), choose active status/intended priority and save. The course reused existing demo easy-test after verifying its test-key prefix; the copied configuration belongs to the isolated course organization, so it does not use a platform-shared paid fallback. Obtain keys from EasyPost API Keys.
POST /shipping/rates accepts both fromAddress and toAddress, parcel length/width/height/weight with dimensionUnit:'in' and weightUnit:'lb', and selected items. It creates a provider quote shipment, not a label purchase. Our quote returned USPS and FedEx rates. Store its shipmentId and select an exact returned rate. POST /shipping/create is a staff operation receiving those same identifiers, addresses, parcel and items. Do not manufacture a rate or silently requote and buy a different shipment.
Our exact USPS GroundAdvantage rate6.57 created shipmentshp_f1234bdd60b84df5806400c228ab0413 in modetest, tracking9434600208192110020066 and a real label image. This standalone training shipment deliberately has no order/customer email, preventing a shipping message. To fulfil a real order, include its actual orderNumber and the items in that parcel. That association updates order shipping state and invokes configured notifications; the following correct-address fulfilment section separately demonstrates that association without sending a notification.
A shipping label cost is distinct from the customer's6.50flat delivery charge. Our existing provider tracker returned in_transit. The current public /shipping/track/{number} route returned400 for this generated test number because it attempted a new EasyPost test tracker; getTracker through the staff provider operation reused the existing tracker successfully. The course documents this exact application finding; it does not claim the public route worked or that the parcel was physically delivered.

Associate the correct shipment and verify stock deduction
The first standalone label was addressed to San Francisco. The paid order HS851V342 has a Chicago destination, so that earlier label was not attached to the order. For fulfilment, we requested a new test quote using the order's unchanged saved Chicago address. The USPS GroundAdvantage test rate was 10.11 USD, not the earlier standalone label's 6.57 USD. The customer's collected delivery charge remained 6.50 USD.
The correct-address test shipment is shp_f6aa94f79af54346b2eca09a68becb14, tracking 9434600208192110020219. Those are recorded evidence identifiers, not reusable inputs for another order. Use your own saved order address, both original product units and exact returned rate/shipment in the carrier quote/label descriptors. For this controlled demonstration the label was created without orderNumber or customer email. Then POST /shipping/order/manual linked that pre-created label's tracking to the order with sendNotification:false. This route records manual/external-label association; it does not purchase another label. The order entry has manual:true and no copied labelUrl, while the actual label remains in its separate shipping record/download.
Edit json/operations/associate-existing-label.json with your order number, matching-address label's exact carrier/service/tracking/cost and the precise included order lines/quantities. Read the order first; if the tracking is already attached, do not add it again. After deliberate confirmation, run the operation once and read the same customer order again. Manual association is not idempotent: a blind retry can duplicate shipping entries and quantities. Direct /shipping/create with an orderNumber follows a different notification path; do not assume its payload accepts the manual route's notification-suppression flag.
The observed association changed this order from paid to shipped, recorded two shipped units and retained the original Stripe tender/reference, amount 54.50, address and order ID. Collected amount remains 54.50 with zero remaining balance. shipped is the fulfilment lifecycle state; it does not mean the payment disappeared. No physical parcel delivery or customer shipping-message delivery is claimed.
Location inventory is a separate prerequisite
First obtain the location identifier. With a staff session, edit and run node operation.mjs json/operations/location-lookup.json. The response is a page whose data array holds location records. Read the selected record's data.name, not its display title or sk: inventory.locationId in this exercise uses that saved stable name. Our existing bm-checkout-course was verified by this lookup; it is not a location that automatically exists in your organization.
If no intended location exists, the downloadable location-create.json supplies the current documented POST /repository/create body for datatype location. Choose a unique name of 3–100 letters/digits/underscores/hyphens, a display title, active status, physical address and appropriate IANA timezone. Replace every placeholder, check that the name is unused, then run CONFIRM_TEST_OPERATION=yes node operation.mjs json/operations/location-create.json once. Re-run lookup and carry the returned data.name into inventory-opening.json. This optional creation contract was checked against the current location schema; the film reuses an existing location and does not claim it created another one. The Business locations section of the organisation lesson shows the corresponding administration screen.
The first stock readback exposed orderInventory.status: needs_attention: this SKU had a product-level stock value but no location inventory record. Marking a shipment did not establish that inventory was deducted. This is the actual recovery performed, with its before/after evidence preserved:
- Read the SKU's inventory through
GET /storefront/inventory/sku/{sku}and confirm the intended business location already exists. The controlled fixture used the existing location namebm-checkout-course. Do not create a duplicate inventory row or alter another business's SKU. - Set the stock location before normal fulfilment. Here, to demonstrate recovery,
POST /storefront/inventorycreated a deliberate opening balance of 30 zero-cost training units, withreservedQuantity:0,unitCost:0,currency:'USD'. Thirty is an explicitly chosen course fixture balance; it was not inferred from an actual business's stock. Zero is an explicit training valuation, not a recommendation to ignore the real acquisition cost. Use your verified quantity and non-negative unit cost for your own inventory. - Edit the guarded
inventory-opening.jsondescriptor with your SKU and existing location name. Inspect existing stock first, because this endpoint also changes an existing record's quantity. Review the body and run only the intended write. - Run the supported retry for the already shipped order; do not add its shipment again. Our route was
POST /storefront/order/stock-retry/HS851V342with{}. It drew exactly two owed units: location stock 30 → 28, orderInventory.statuscomplete, drawn quantity 2, issues empty. The existing single stock location was resolved automatically. If more than one location stocks a SKU, the order's business location must identify the intended stock location; generic order/update does not accept that field. - Read both the order and inventory again. Compare payment reference/tender, collected balance, address, tracking, quantity and stock transaction. Repeating the stock-retry once left stock 28 and retained the same transaction
INV-29EB45F9A86A450B915DA7D3; no second deduction occurred.
# Set STAFF_TOKEN privately; replace all YOUR_ markers and inspect the bodies first.
node operation.mjs json/operations/inventory-read.json
CONFIRM_TEST_OPERATION=yes node operation.mjs json/operations/inventory-opening.json
CONFIRM_TEST_OPERATION=yes node operation.mjs json/operations/order-stock-retry.json
node operation.mjs json/operations/inventory-read.jsonThese commands deliberately separate the opening-stock write from the supported stock retry. A subsequent read of the new shipment’s existing EasyPost tracker returned mode:test, status:delivered for this same tracking number. That is simulated provider tracking; it did not mark a physical delivery or automatically change the order from shipped. The earlier public-route error belongs to the separate first test shipment. The recorded responses, original needs_attention outcome, corrected order, matching test label and no-double-draw readback are under observed-json/payment-revision/fulfilment. The original Stripe payment and saved shipping address were unchanged throughout.



Gift cards and other selling models
A gift card is a tender, not a coupon. Its headless integration uses customer GET /client/giftcards/balance?code=..., calculation with giftCards:[{code,pin?}], and the same giftCards on /storefront/checkout-cart. Quote allocation must not spend it; checkout performs the order-linked redemption. A completed demonstration must compare the paid order's giftcard tender with the matching balance reduction. New course gift-card issuance is pending specific owner authorization after automatic approval review rejected creating redeemable value. This branch is not marked complete.
Rentals and subscriptions have different inventory/time/billing outcomes; they are not demonstrated by these physical-product purchases. The existing written Products, Shipping, Payment Gateways, Discounts, Gift Cards and Rental lessons explain those module controls. Their stated limitations remain in force; a linked draft is not claimed as a completed missing outcome.
Run equivalent TypeScript, JavaScript, Python, curl and Postman requests
Use private environment values and your own records:
export APPENGINE_ENDPOINT='https://YOUR_APPENGINE_HOST'
export ORG_ID='YOUR_ORGANIZATION_ID'
export CUSTOMER_EMAIL='YOUR_CONTROLLED_CUSTOMER_EMAIL'
export CUSTOMER_PASSWORD='YOUR_CUSTOMER_PASSWORD'
node client.mjs signin
node client.mjs products
node client.mjs product HEADLESS-CEDAR-1010
node client.mjs quote json/quote.json > quote-result.json
node client.mjs save quote-result.json json/address.json
node client.mjs cart
node client.mjs order YOUR_RETURNED_ORDER_NUMBERclient.py provides the same basic command flow with Python's standard library. client.sh usescurl and printsJSON directly. client.ts reuses the included Appmint JavaScript SDK through an explicit customer bearer adapter; the Next and Remix clients use their existing fetch transports, not that SDK. Install/build from this package root with npm install && npm run build, then run the generated dist/client.js commands. The mocked SDK tests alone do not establish paid checkout.
operation.mjs, operation.ts, operation.py and operation.sh execute exact request descriptors in json/operations/. Replace marked IDs with your own saved values. Set CUSTOMER_TOKEN for customer operations or STAFF_TOKEN for staff-only fulfilment. A write markedmutation requires deliberate CONFIRM_TEST_OPERATION=yes after reviewing the configured sandbox and body. Browser payment approval belongs in the complete checkout application; a curl request does not replace card/PayPal approval.
node operation.mjs json/operations/option-quote.json
python3 operation.py json/operations/coupon-quote.json
node operation.mjs json/operations/carrier-quote.json > carrier-quote-result.json
# Copy the exact returned shipmentId and selected rate into carrier-label.json.
# Use staff identity and verified test provider for that deliberate label operation.The root build includes both client.ts and operation.ts. After npm install && npm run build, run node dist/operation.js json/operations/option-quote.json from the package root. The shell equivalent is sh operation.sh json/operations/option-quote.json; it uses Python 3 solely to parse the descriptor safely and curl for the HTTP request. All generic runners require the same explicit customer/staff token and mutation confirmation; they do not read the CLI's private session file automatically.
Import commerce.postman.json, populate its environment locally, and keep exported values empty. Its requests identify customer versus staff identity. Full JSON bodies are supplied; no jq is required. observed-json is evidence, not a configuration file or set of reusable IDs for your organization.
Deployment boundary
This is a local teaching server with in-memory sessions, private disk attempts and a single-process lock. A public deployment needs HTTPS, secure cookie attributes, an exact allowed origin, a shared expiring session/attempt store and operational reconciliation across instances. Keep private attempts and provider secrets outside served files. Do not merely copy the local process architecture into a multi-instance production deployment. Application defects unrelated to course completion were documented, not patched in application source.
Production and evidence boundaries
The film uses actual isolated local Studio and reused client procedures, with browser chrome excluded. Public addresses are explained as deployment choices; no hosted production release is claimed. Provider secrets and customer passwords are excluded. Configuration forms are deliberately blank, while saved connection tests and provider/order readbacks establish the actual configured sandbox outcomes. Current source and exact sanitized responses accompany the clients. AppEngine application source was not changed for this production.
AppEngine Headless storefront, cart and checkout APIs — supporting guide
Companion source code
Course examples and setup on GitHub. This module shares its companion with the linked consolidated course.
Why use AppEngine behind your own shop
You want your own shop front: your design, your framework, maybe a mobile app. What you don't want to write is everything behind the "Pay" button — product prices for this customer, coupons, tax, shipping rules per address, a basket that survives a page refresh, checking the card payment matches the order, stock counts, receipts and the order record staff will fulfil. AppEngine already does all of that. Your storefront shows things and collects the customer's choices; AppEngine prices, saves, checks the charge and records the sale.
In this course you build one checkout, start to finish: a signed-in customer, Dana, buys one bag of coffee beans (SKU house-blend-1kg) for delivery, pays by card through Stripe, and then sees her order. Every call runs on your own server.
1. What AppEngine already has for a shop
Built-in data types
A data type (also called a datatype or collection) is a kind of record AppEngine already knows, with its fields defined. Every record has the same envelope: an sk (the record's unique ID), a datatype, and a data object holding the business fields. The shop uses these:
| Data type | What it holds | Fields you will meet |
|---|---|---|
sf_product | a product staff maintain in Storefront → Product | sku, name, price, status, stock, image, variations, parcel |
customer | a shopper with an account | email, firstName, lastName, phone |
sf_cart | one customer's saved basket | productItems[] (sku, quantity, unitPrice, amount), shippingAddress, discounts, subtotal, tax, productShipping, total, shippingMethod |
sf_order | a placed order | number, status, email, productItems, amount, amountPaid, paymentGateway, paymentRef, shippingAddress |
sf_transaction | one payment recorded against an order | gateway, ref, amount, status |
sf_discount | a coupon or automatic promotion | code, type, status |
(The full schemas are in the SDK under sdk/src/models/storefront.)
Built-in actions
These endpoints run the shop's rules for you. Each one is a single call:
| You call | AppEngine does for you |
|---|---|
GET storefront/products | lists the products a shopper may see, with paging and filters (p, ps, categories, brand, minPrice, maxPrice) |
GET storefront/product/:id | returns one product by SKU, slug or ID, with calculatedPrice worked out for this customer (price lists, volume tiers, group prices) |
POST storefront/pricing/calculate-cart | prices every line for this customer, applies the coupon and any automatic promotions, works out shipping for the address and tax, and returns the totals plus a cartId |
POST shipping/options | lists every way the items can ship to an address under the shop's shipping rules, cheapest first — or says the address isn't served |
POST storefront/cart/update/:cartId | saves the basket and recalculates it from the saved record |
GET storefront/cart/mine | brings back the signed-in customer's saved basket (or null) |
GET storefront/payment-gateways | lists the shop's payment providers with only their public settings (for Stripe, the publishableKey) |
POST storefront/stripe/intent | re-prices the saved cart, refuses the charge if your amount doesn't match it, then opens a Stripe payment for that amount |
POST storefront/checkout-cart | places the order — see the full list in section 8 |
GET client-data/orders | lists the signed-in customer's own orders |
The rule
- Use the built-in action whenever one exists. If you wrote an
sf_orderrecord yourself you would skip the price check, the payment check, the stock count, the receipt email and the books entry. - Use the built-in data type for the thing itself. If your products need an extra field (say,
roastLevel), add it tosf_productfor your organization — your fields are merged over the built-in ones — rather than starting amy_productscollection that no checkout understands. - Create your own collection only for something the shop doesn't have (tasting-panel notes, for example), and read and write it with the generic record routes covered in Data records.
2. Set up what the storefront needs
In the shop's admin
Your storefront can only sell what the shop has configured. In Appmint (staff side), make sure the organization has:
- an active product — here
house-blend-1kg— with a price and a parcel weight, in Storefront → Product; - a shipping rule that covers Dana's address;
- a Stripe payment gateway in test mode.
Shop setup and order fulfilment walks through each of these screens.
Your application's credentials
Three values identify your storefront to AppEngine:
- Organization ID — which business you are talking to. It goes in an
orgidheader on every request. - App ID and app secret — your storefront's own login. You exchange them for an app token (a bearer token: a string you send in
Authorization: Bearer …), which proves your application is calling.
Creating them is covered in API foundations. Keep the secret on your server only. Put the values in .env.local:
APPENGINE_ENDPOINT=https://appengine.appmint.io
ORG_ID=your-org-id
APP_ID=your-app-id
APP_SECRET=your-app-secretWho is buying: the customer token
The app token says which application is calling. It says nothing about the shopper. When Dana signs in, AppEngine gives you a customer token, and you send it on her requests in a second header, x-client-authorization. Cart, checkout and order history all read the buyer from that header.
/profile/customer/signinThe response carries token (Dana's customer token). If the shop has two-step sign-in turned on, it instead returns requiresTwoFactor: true and a challengeToken; Authentication and organization isolation shows how to finish that step and how to keep the token in an httpOnly session cookie.
Keep each customer's token with their request. Never store it in a variable shared by every request on your server, or one shopper's basket will show up for another.
A small server helper
Everything below runs on your server (Node 20.6+ for --env-file). This helper gets the app token once, sends both headers, and renews the app token once if it expires:
// appengine.mjs
const BASE = process.env.APPENGINE_ENDPOINT;
const ORG = process.env.ORG_ID;
let appToken = null;
async function getAppToken() {
if (appToken) return appToken;
const res = await fetch(`${BASE}/profile/app/key`, {
method: 'POST',
headers: { 'content-type': 'application/json', orgid: ORG },
body: JSON.stringify({ appId: process.env.APP_ID, secret: process.env.APP_SECRET }),
});
if (!res.ok) throw new Error(`App sign-in failed (${res.status})`);
appToken = (await res.json()).token;
return appToken;
}
export async function call(method, path, { body, customerToken } = {}, retried = false) {
const headers = { orgid: ORG, authorization: `Bearer ${await getAppToken()}` };
if (body !== undefined) headers['content-type'] = 'application/json';
if (customerToken) headers['x-client-authorization'] = customerToken;
const res = await fetch(`${BASE}/${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 401 && !retried) { // the app token may have aged out
appToken = null;
return call(method, path, { body, customerToken }, true);
}
const text = await res.text();
const payload = text ? JSON.parse(text) : null;
if (!res.ok) throw Object.assign(new Error(payload?.message || res.statusText), { status: res.status, payload });
return payload;
}If you prefer a ready-made version, the official example shop appmint-examples/nextjs-store does the same in lib/appmint.ts, and its app/api/[...slug]/route.ts shows how the browser talks to your server instead of to AppEngine, so the app secret never reaches a browser.
3. Show the catalogue
// catalogue.mjs
import { call } from './appengine.mjs';
const customerToken = process.env.CUSTOMER_TOKEN;
const list = await call('GET', 'storefront/products?p=1&ps=12', { customerToken });
const beans = await call('GET', 'storefront/product/house-blend-1kg', { customerToken });
console.log(list.data.length, beans.data.name, beans.data.calculatedPrice?.finalPrice ?? beans.data.price);node --env-file=.env.local catalogue.mjsThe list comes back as a page of records under data; each record has its own data with the product fields. The single product is one record:
{
"sk": "6702c1…",
"datatype": "sf_product",
"data": {
"sku": "house-blend-1kg",
"name": "House Blend, 1 kg",
"price": 24,
"status": "available",
"calculatedPrice": { "originalPrice": 24, "finalPrice": 21.6, "discount": 2.4, "appliedRule": "Members 10%" }
}
}Show calculatedPrice.finalPrice — it is what this customer pays, after her group's price list or volume tier. Sending the customer token on catalogue reads is what makes that happen; without it you get the public price.
4. Price the basket
Dana adds one bag. Your server sends the lines (just SKU and quantity — never a price from the browser) to the pricing action:
/storefront/pricing/calculate-cart{
"cartId": "6702c9…",
"valid": true,
"productItems": [{ "sku": "house-blend-1kg", "quantity": 1, "unitPrice": 21.6, "amount": 21.6 }],
"subtotal": 21.6,
"discount": 5,
"discounts": [{ "code": "WELCOME5", "name": "Welcome $5 off", "amount": 5, "type": "fixed" }],
"tax": 0,
"productShipping": 0,
"total": 16.6
}From your server the same call is:
const firstQuote = await call('POST', 'storefront/pricing/calculate-cart', {
customerToken,
body: { productItems: [{ sku: 'house-blend-1kg', quantity: 1, itemType: 'product' }], couponCode: 'WELCOME5' },
});AppEngine has priced the line for Dana, checked the coupon against its rules (dates, usage limits, first-order-only and so on), added any automatic promotion, and returned a cartId — her basket's ID, created if she didn't have one. Display exactly these numbers. total already includes tax and shipping; don't add them again.
If the coupon can't be used, the call still succeeds but returns valid: false with a message such as "Discount has expired". Show that message beside the coupon box and keep the rest of the basket.
5. Add the delivery address and check delivery
Until there is a full address, treat the quote as a preview of the items. Once Dana enters her address, ask which ways the bag can ship there:
const shippingAddress = { street1: '12 Pier Road', city: 'Portland', state: 'ME', zip: '04101', country: 'US' };
const delivery = await call('POST', 'shipping/options', {
customerToken,
body: { items: [{ sku: 'house-blend-1kg', quantity: 1 }], toAddress: shippingAddress },
});{
"served": true,
"currency": "USD",
"options": [
{ "label": "Standard", "method": "flat", "amount": 6.5 },
{ "label": "UPS Ground", "method": "carrier", "carrier": "ups", "service": "Ground", "amount": 9.12 }
]
}served: false means no shipping rule covers that address; show message (and, for staff, problems) and keep the Pay button disabled.
Then price the basket again, now with the address. An address counts as complete once it has street1, city and country:
const quote = await call('POST', 'storefront/pricing/calculate-cart', {
customerToken,
body: {
cartId: firstQuote.cartId,
productItems: [{ sku: 'house-blend-1kg', quantity: 1, itemType: 'product' }],
couponCode: 'WELCOME5',
shippingAddress,
},
});
// quote.productShipping === 6.5, quote.shippingMethod, quote.total === 23.1The quote now carries productShipping, the shippingMethod — the cheapest of the options listed above — and the new total. If one of the products may not be sent to that region, the quote comes back with shippingBlocked holding the reason — show it and don't offer payment.
Re-price every time the address, quantity or coupon changes, and only show the newest answer. If two quotes are in flight, ignore the older one when it arrives.
6. Save the basket
The quote is a calculation; nothing is stored yet. Save the basket so it survives a refresh, another device, and — most importantly — so the payment and the order can be checked against it:
const saved = await call('POST', `storefront/cart/update/${encodeURIComponent(quote.cartId)}`, {
customerToken,
body: {
datatype: 'sf_cart',
data: {
productItems: quote.productItems,
couponCode: 'WELCOME5',
shippingAddress,
email: '[email protected]',
source: 'website',
},
},
});
console.log(saved.cartId, saved.data.total); // same cartId, total recalculated from the saved recordAppEngine stores the sf_cart and recalculates it from what it saved, so saved.data holds the totals you should now show.
When Dana comes back later, restore her basket:
const cart = await call('GET', 'storefront/cart/mine', { customerToken });
// cart?.sk is her cartId; cart?.data.productItems are her lines; null means she has no saved basketcart/mine only ever returns the signed-in customer's own basket. A 401 means her session has ended — ask her to sign in again.
7. Take the card payment
First, find the shop's Stripe settings:
const gateways = await call('GET', 'storefront/payment-gateways');
const stripeGw = gateways.find((g) => g.data.provider === 'StripeProvider');
// stripeGw.data.publishableKey — safe to send to the browserThen open a payment for the saved basket. Send the total in normal currency units (23.1, not cents) and the cartId:
/storefront/stripe/intent{
"id": "pi_3Q…",
"client_secret": "pi_3Q…_secret_…",
"amount": 2310,
"currency": "usd",
"status": "requires_payment_method",
"publishableKey": "pk_test_…"
}Before Stripe is called, AppEngine re-prices the saved cart. If your amount doesn't match it — say the coupon expired a minute ago — you get 409 "The amount to pay no longer matches your cart" and nothing is charged; re-price and show Dana the new total. The returned amount is in cents because it is Stripe's own object.
In the browser, hand client_secret and publishableKey to Stripe.js and let Dana enter her card:
const stripe = Stripe(publishableKey);
const elements = stripe.elements({ clientSecret });
elements.create('payment').mount('#card');
// on Pay:
const { paymentIntent, error } = await stripe.confirmPayment({ elements, redirect: 'if_required' });
// paymentIntent.id ("pi_3Q…") goes back to your server8. Place the order
Your server now places the order, using the saved basket and the payment ID Stripe returned:
const order = await call('POST', 'storefront/checkout-cart', {
customerToken,
body: {
cartId: saved.cartId,
productItems: saved.data.productItems,
email: '[email protected]',
name: 'Dana Reyes',
phone: '+12075550100',
shippingAddress,
billingAddress: shippingAddress,
shippingMethod: saved.data.shippingMethod,
paymentGateway: 'stripe',
paymentRef: paymentIntentId, // the paymentIntent.id the browser sent back
currency: 'USD',
},
});That one call does all of this for you:
- reads the saved cart by
cartIdand refuses if the lines you sent differ from it; - re-prices every line for Dana and re-applies the discounts saved on the cart;
- refuses if a product can't ship to the address;
- asks Stripe what was actually charged and refuses — refunding the card — if it doesn't match the order total;
- writes the
sf_orderand ansf_transactionfor the payment, and marks the orderpaidonce the payment covers the total; - counts down stock for products that track inventory, and records the coupon's use;
- posts the sale to the business's books;
- clears the cart, starts the shop's order workflow, and emails Dana the order confirmation (and the owner a copy, if the shop asked for one).
It returns the order:
{
"sk": "6702d4…",
"datatype": "sf_order",
"data": {
"number": "K7Q2M9XA4",
"status": "paid",
"email": "[email protected]",
"productItems": [{ "sku": "house-blend-1kg", "quantity": 1, "unitPrice": 21.6, "amount": 21.6 }],
"productShipping": 6.5,
"discount": 5,
"amount": 23.1,
"paymentGateway": "stripe",
"paymentRef": "pi_3Q…"
}
}Show Dana data.number on the thank-you page. Keep the paymentRef in your logs: if the network drops after Stripe confirmed but before you saw the order, it is how you find out what happened.
9. Show the customer her orders
Dana's account page lists her orders and opens one by its number:
const mine = await call('GET', 'client-data/orders?page=1&limit=10', { customerToken });
const one = await call('GET', `client-data/orders/${encodeURIComponent('K7Q2M9XA4')}`, { customerToken });Both read the buyer from her token, so she only ever sees her own orders. For a shopper who checked out as a guest, the order-status page can use GET storefront/order/email/:email/:orderNumber — it returns the order only when both the email and the number match.
Staff see the same order in Appmint under Storefront → Order → Order Management, where they ship it, refund it or cancel it.
10. When something changes or goes wrong
| What happened | What AppEngine returns | What your storefront does |
|---|---|---|
| Coupon can't be used | calculate-cart → valid: false, message | Show the message by the coupon box; keep the basket |
| Address isn't served | shipping/options → served: false, message | Ask for another address; keep Pay disabled |
| A product can't go to that region | quote → shippingBlocked | Show the reason; offer to remove the item |
| Price changed before paying | stripe/intent → 409, nothing charged | Re-price, show the new total, open a new payment |
| Basket changed during checkout | checkout-cart → 409; any card payment is refunded | Restore the cart with cart/mine, re-price, check out again |
| Dana's session ended | 401 on cart/mine, client-data/… | Ask her to sign in; her saved basket is still there |
| Network dropped after payment | — | Look up the order in client-data/orders before trying again |
Two more paths the same checkout supports:
- Rentals in the same basket.
POST storefront/checkout-mixedtakes anitemsarray where each line hasitemTypeproductorrentaland, for rentals,startDateandendDate; it places the order and creates the rental bookings. See Logistics and rental APIs. - Refunds. Refunds are staff actions on the order; see Finance APIs.
11. Find the rest of the shop's API
Now that you know which actions you use, the API reference on your AppEngine server lists every one with its full request and response. Open https://appengine.appmint.io/documentation and search storefront — you will find the routes from this course next to categories, brands, collections, gift cards and subscriptions. Search shipping for delivery tracking, and client-data for the rest of the customer's account (addresses, saved cards, wishlist). API reference shows how to read an entry.
Where to go next
- Headless storefront development — the same purchase as one complete running app.
- Customer portal — the rest of the signed-in customer's account.
- Integrations and webhooks — connecting a carrier for live rates and labels.