← Home

Storefront API

Public REST API used by your custom storefront to read the catalog and complete checkout.

Authentication

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" },
})

Errors

All errors are JSON in the same envelope:

{ "error": { "code": "unauthorized", "message": "Missing x-storefront-key" } }

Common codes:

  • unauthorized — missing/invalid publishable key
  • not_found — product, collection, or cart not found
  • bad_request — malformed body or query
  • validation_error — Zod schema rejected the body
  • stripe_not_enabled — checkout blocked; merchant hasn't finished Stripe onboarding

Catalog

GET/api/storefront/products

List 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.

GET/api/storefront/products/:handle

Single product by handle.

GET/api/storefront/products/batch?handles=a,b,c

Hydrate 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([...]).

GET/api/storefront/products/:handle/related

Curated cross-sell list (max 12). Order matches what the merchant set.

GET/api/storefront/collections

List collections. Cursor pagination.

GET/api/storefront/collections/:handle

Single collection by handle.

Cart

POST/api/storefront/carts

Create a cart. Body: { currency?, email? }.

GET/api/storefront/carts/:id

Fetch a cart with totals. Includes free_shipping_threshold and remaining_to_free_shipping (both nullable) when the merchant has configured an incentive.

POST/api/storefront/carts/:id/lines

Add a line. Body: { variant_id, quantity }.

PATCH/api/storefront/carts/:id/lines/:lineId

Update quantity. Body: { quantity }. Setting quantity=0 removes the line.

DELETE/api/storefront/carts/:id/lines/:lineId

Remove a line.

POST/api/storefront/carts/:id/discount

Preview a discount code. Body: { code }.

Checkout

POST/api/storefront/carts/:id/shipping-rates

Body: { 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.

POST/api/storefront/checkout/start

Body: { 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.

Back in stock

POST/api/storefront/variants/:id/back-in-stock

Body: { 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.

Reviews

GET/api/storefront/products/:handle/reviews

Approved reviews + aggregate rating for the product. Cursor pagination.

POST/api/storefront/reviews

Submit 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.

Wishlist

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).

GET/api/storefront/wishlist

The customer's saved products as full product objects.

POST/api/storefront/wishlist

Add a product. Body: { product_id }. Idempotent. Returns { items }.

DELETE/api/storefront/wishlist/:productId

Remove a product. Returns the updated { items }.

SDK: sdk.wishlist.list() / .add(productId) / .remove(productId).

Email signup

POST/api/storefront/signup

Body: { 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? }).

SDK

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
});

Webhooks (outbound)

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));
}