Public REST API used by your custom storefront to read the catalog and complete checkout.
Every request must include an x-storefront-key header set to a publishable key. Generate keys at /admin/settings/api-keys. Publishable keys are safe to ship to the browser.
fetch("https://your-app.com/api/storefront/products", {
headers: { "x-storefront-key": "pk_live_XXXX_YYYY" },
})All errors are JSON in the same envelope:
{ "error": { "code": "unauthorized", "message": "Missing x-storefront-key" } }Common codes:
unauthorized — missing/invalid publishable keynot_found — product, collection, or cart not foundbad_request — malformed body or queryvalidation_error — Zod schema rejected the bodystripe_not_enabled — checkout blocked; merchant hasn't finished Stripe onboarding/api/storefront/productsList active products. Cursor pagination via ?limit=&cursor= (newest sort only). Optional: ?collection=:handle, ?q=search (title + description), ?tag=a&tag=b (or ?tag=a,b — ANY match), ?min_price=&max_price= (minor units), ?sort=newest|price_asc|price_desc.
/api/storefront/products/:handleSingle product by handle.
/api/storefront/products/batch?handles=a,b,cHydrate several products by handle in one call, returned in the order requested (active only, unknown handles dropped, max 50). Use for 'recently viewed' rails — track handles client-side, then batch-fetch. SDK: sdk.products.byHandles([...]).
/api/storefront/products/:handle/relatedCurated cross-sell list (max 12). Order matches what the merchant set.
/api/storefront/collectionsList collections. Cursor pagination.
/api/storefront/collections/:handleSingle collection by handle.
/api/storefront/cartsCreate a cart. Body: { currency?, email? }.
/api/storefront/carts/:idFetch a cart with totals. Includes free_shipping_threshold and remaining_to_free_shipping (both nullable) when the merchant has configured an incentive.
/api/storefront/carts/:id/linesAdd a line. Body: { variant_id, quantity }.
/api/storefront/carts/:id/lines/:lineIdUpdate quantity. Body: { quantity }. Setting quantity=0 removes the line.
/api/storefront/carts/:id/lines/:lineIdRemove a line.
/api/storefront/carts/:id/discountPreview a discount code. Body: { code }.
/api/storefront/carts/:id/shipping-ratesBody: { shipping_address }. Returns { rates: [{ id, name, price }] } — the applicable shipping methods for this cart + address, cheapest first. Render a picker, then pass the chosen id as shipping_rate_id to checkout/start.
/api/storefront/checkout/startBody: { cart_id, email, shipping_address, billing_address?, shipping_rate_id? }. Returns { payment_intent_client_secret, order_preview }. Confirm with Stripe.js client-side.
Omit shipping_rate_id to charge the cheapest applicable rate. A chosen rate is re-validated server-side and rejected with 422 if it no longer applies (e.g. the cart total changed) — re-fetch the options when that happens.
Card data is captured by Stripe Elements on your storefront and confirmed with the returned client_secret. The platform never sees a raw card number.
/api/storefront/variants/:id/back-in-stockBody: { email }. Subscribes the visitor to a one-shot restock notification. Idempotent. When the variant total goes from 0 → >0, every pending subscriber gets a single transactional email via the Suppression Guard.
/api/storefront/products/:handle/reviewsApproved reviews + aggregate rating for the product. Cursor pagination.
/api/storefront/reviewsSubmit a review. Body: { product_id, author_name, author_email, rating (1–5), title?, body, order_id? }. Lands as `pending` until a merchant approves.
The list response includes aggregate.count and aggregate.average — drop them straight into a JSON-LD AggregateRating for SEO.
Per-customer. All wishlist requests require a customer token (x-customer-token) on top of the publishable key — see Customer auth. Each returns the full wishlist (newest first).
/api/storefront/wishlistThe customer's saved products as full product objects.
/api/storefront/wishlistAdd a product. Body: { product_id }. Idempotent. Returns { items }.
/api/storefront/wishlist/:productIdRemove a product. Returns the updated { items }.
SDK: sdk.wishlist.list() / .add(productId) / .remove(productId).
/api/storefront/signupBody: { email, first_name?, list_id? }. Subscribes the contact (or sets pending status when the list requires double opt-in).
SDK: await sdk.newsletter.subscribe({ email, list_id? }).
Install @platform/sdk (workspace package) and use the typed client:
import { createStorefrontClient } from "@platform/sdk";
const sdk = createStorefrontClient({
baseUrl: "https://your-app.com",
publishableKey: process.env.NEXT_PUBLIC_SHOPNOW_KEY!,
});
const { items, next_cursor } = await sdk.products.list({ limit: 20 });
const product = await sdk.products.get("classic-tee");
const cart = await sdk.cart.create({ currency: "USD" });
await sdk.cart.addLine(cart.id, { variant_id: product.variants[0].id, quantity: 1 });
const shipping_address = { address_line1: "123 Main", city: "Brooklyn", region: "NY", postal_code: "11201", country: "US" };
// Show the shopper the available methods, let them pick one.
const { rates } = await sdk.cart.shippingRates(cart.id, { shipping_address });
const { payment_intent_client_secret } = await sdk.checkout.start({
cart_id: cart.id,
email: "buyer@example.com",
shipping_address,
shipping_rate_id: rates[0]?.id, // the method the shopper chose
});Configure outbound endpoints in /admin/settings/webhooks. Each delivery includes an x-shopnow-signature header in the format t=<unix>,v1=<hex>. Verify with your endpoint secret:
import crypto from "node:crypto";
function verify(body, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${body}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}