> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shoppex.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Storefront SDK reference

> Complete method and type reference for the Shoppex Storefront SDK

This page lists every method and type in `@shoppexio/storefront`, grouped by object: Store, Products, Cart, Checkout, Reviews and invoices, and Types.

<Note>
  All async SDK methods return `SDKResponse<T>`, a wrapper containing `data`, `error`, and `status` fields. See [Response types](#response-types) for the full definition.
</Note>

## Store

The Store object gives access to your shop's public information, including name, branding, and settings.

<Tip>
  For simple cases such as displaying the store logo, use `getStoreLogoUrl()` or `getStoreBannerUrl()`. They are lightweight wrappers that avoid fetching the full store metadata.
</Tip>

### getStore

Fetches the store's public metadata.

```javascript theme={"system"}
const { data: store } = await shoppex.getStore();

console.log(store.name);                 // "My Awesome Store"
console.log(store.currency);             // "EUR" — the currency this response is priced in
console.log(store.default_currency);     // "USD" — what the shop prices in when nothing is requested
console.log(store.available_currencies); // ["USD", "EUR"] — what the shop sells in
```

`currency` is the currency every price in the same response is denominated in: the one you passed to `shoppex.init`, otherwise the shop default or its country auto-detection. Pass a currency outside `available_currencies` and the call fails with `code: 'errors.storefront.currency_unavailable'`.

**Response**

<ResponseField name="data" type="Shop">
  <Expandable title="Shop object">
    <ResponseField name="id" type="string">
      Unique store identifier
    </ResponseField>

    <ResponseField name="name" type="string">
      Store display name
    </ResponseField>

    <ResponseField name="slug" type="string">
      URL-friendly store identifier
    </ResponseField>

    <ResponseField name="domain" type="string">
      Custom domain if configured
    </ResponseField>

    <ResponseField name="description" type="string">
      Store description
    </ResponseField>

    <ResponseField name="currency" type="string">
      Currency this response is priced in (ISO 4217)
    </ResponseField>

    <ResponseField name="default_currency" type="string">
      The currency the shop prices in when nothing is requested (ISO 4217). Always one of `available_currencies`.
    </ResponseField>

    <ResponseField name="available_currencies" type="string[]">
      Currencies enabled for the shop. Empty means the default only.
    </ResponseField>

    <ResponseField name="logo" type="string">
      Logo image URL
    </ResponseField>

    <ResponseField name="banner" type="string">
      Banner image URL
    </ResponseField>

    <ResponseField name="rating" type="number">
      Average store rating (1-5)
    </ResponseField>

    <ResponseField name="tos_enabled" type="boolean">
      Whether terms of service are enabled
    </ResponseField>

    <ResponseField name="social" type="object">
      Social media links

      <Expandable>
        <ResponseField name="discord" type="string">Discord URL</ResponseField>
        <ResponseField name="twitter" type="string">Twitter URL</ResponseField>
        <ResponseField name="instagram" type="string">Instagram URL</ResponseField>
        <ResponseField name="facebook" type="string">Facebook URL</ResponseField>
        <ResponseField name="telegram" type="string">Telegram URL</ResponseField>
        <ResponseField name="youtube" type="string">YouTube URL</ResponseField>
        <ResponseField name="reddit" type="string">Reddit URL</ResponseField>
        <ResponseField name="tiktok" type="string">TikTok URL</ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

**Example: display store header**

```javascript theme={"system"}
async function renderStoreHeader() {
  const { data: store } = await shoppex.getStore();

  document.getElementById('store-header').innerHTML = `
    <img src="${store.logo}" alt="${store.name}" class="logo" style="border-radius: 9999px;">
    <h1>${store.name}</h1>
    ${store.rating ? `<span class="rating">${store.rating.toFixed(1)} / 5</span>` : ''}
  `;
}
```

### getStoreLogoUrl

Returns the store's logo URL directly.

```javascript theme={"system"}
const logoUrl = await shoppex.getStoreLogoUrl();

if (logoUrl) {
  document.getElementById('logo').src = logoUrl;
}
```

**Response**

<ResponseField name="return" type="string | null">
  Logo URL or `null` if no logo is set
</ResponseField>

### getStoreBannerUrl

Returns the store's banner URL directly.

```javascript theme={"system"}
const bannerUrl = await shoppex.getStoreBannerUrl();

if (bannerUrl) {
  document.getElementById('hero').style.backgroundImage = `url(${bannerUrl})`;
}
```

**Response**

<ResponseField name="return" type="string | null">
  Banner URL or `null` if no banner is set
</ResponseField>

### Store error handling

```javascript theme={"system"}
try {
  const { data, success, message, code, errorParams } = await shoppex.getStore();

  if (!success) {
    if (code === 'errors.storefront.currency_unavailable') {
      // The currency from shoppex.init is not enabled for this shop.
      console.error('Enabled currencies:', errorParams.available);
      return;
    }
    console.error('Failed to load store:', message);
    return;
  }

  // Use store data
} catch (error) {
  if (error.name === 'NotInitializedError') {
    console.error('SDK not initialized. Call shoppex.init() first.');
  } else if (error.name === 'NetworkError') {
    console.error('Network error. Check your connection.');
  }
}
```

## Products

Fetch products from your store, including variants, addons, and custom fields.

### getProducts

Fetches all products from the store.

```javascript theme={"system"}
const { data: products } = await shoppex.getProducts();

products.forEach(product => {
  console.log(product.title, product.price);
});
```

**Response**

<ResponseField name="data" type="Product[]">
  Array of products
</ResponseField>

<Expandable title="Product object">
  <ResponseField name="uniqid" type="string">
    Unique product identifier
  </ResponseField>

  <ResponseField name="title" type="string">
    Product name
  </ResponseField>

  <ResponseField name="price" type="string">
    Base price as string (for precision). Use `shoppex.formatPrice()` to display.
  </ResponseField>

  <ResponseField name="currency" type="string">
    Currency code (ISO 4217)
  </ResponseField>

  <ResponseField name="slug" type="string">
    URL-friendly product identifier
  </ResponseField>

  <ResponseField name="description" type="string">
    Full product description (HTML)
  </ResponseField>

  <ResponseField name="description_tabs" type="{ title: string; content: string }[]">
    Merchant-defined extra description tabs (for example, Features, Specifications). `content` is HTML. Empty array when the product has no extra tabs.
  </ResponseField>

  <ResponseField name="badges" type="ProductBadge[]">
    Merchant-curated labels the buyer is meant to see — "Undetected", "New", "Restocked". Defined once per shop (label, icon, colour) and assigned per product, optionally with an expiry, so a badge can disappear on its own.

    Already resolved for you: expired assignments are gone from this array, and each entry carries `label`, `icon`, `background_color`, plus `show_on_product_card` / `show_on_product_page` so you can honour where the merchant wanted it shown. At most 4 badges per product, and an empty array when it has none.

    This is the right field for anything visual and buyer-facing. Reach for `metadata` below only for values your own code reads.
  </ResponseField>

  <ResponseField name="metadata" type="Record<string, string>">
    Merchant-defined key/value pairs, set per product under **Product → Integrations → Metadata**. Values are always strings, and the object is always present (`{}` when the merchant set none), so you can read `product.metadata.status` without a guard.

    Use it for values your theme needs but Shoppex has no field for — a grouping tag, an external ID, a flag your code branches on. Because the merchant edits it in the dashboard, changing a value does not require a new build of your storefront.

    Nothing renders metadata for you. If what you want is a label the buyer should see — "Undetected", "New", "Restocked" — use the `badges` field above instead: those are curated presets with a label, icon and colour, and Shoppex renders them on hosted themes too.

    <Warning>
      Metadata is public. It ships in the storefront payload and is readable by anyone who opens your store. Never put API keys, supplier prices, or anything else private in it.
    </Warning>
  </ResponseField>

  <ResponseField name="images" type="ProductImage[]">
    Array of product images
  </ResponseField>

  <ResponseField name="cdn_image_url" type="string | null">
    Optimized cover image for cards, listings, search results, and other non-zoomed UI.
  </ResponseField>

  <ResponseField name="detail_image_url" type="string | null">
    High-resolution primary image for product detail pages, galleries, and zoom views.
  </ResponseField>

  <ResponseField name="video_link" type="string | null">
    Optional product video URL (YouTube, Streamable, or Vimeo). `null` when no video is configured.
  </ResponseField>

  <ResponseField name="variants" type="ProductVariant[]">
    Available variants (for example, size, color)
  </ResponseField>

  <ResponseField name="addons" type="ProductAddon[]">
    Optional add-ons
  </ResponseField>

  <ResponseField name="price_variants" type="PriceVariant[]">
    Price-based variants (for example, subscription tiers)
  </ResponseField>

  <ResponseField name="custom_fields" type="CustomFieldDefinition[]">
    Custom input fields for checkout
  </ResponseField>

  <ResponseField name="stock" type="number">
    Available stock quantity
  </ResponseField>

  <ResponseField name="categories" type="ProductCategory[]">
    Product categories as objects with `uniqid` and `title`
  </ResponseField>
</Expandable>

<Note>
  Product prices are returned as `string` types to preserve decimal precision. Always use `shoppex.formatPrice()` for display. Do not calculate directly with price strings.
</Note>

<Note>
  Use product images by surface:

  * `cdn_image_url` for product cards, category grids, cart rows, and search results
  * `detail_image_url` for product detail pages, image galleries, and zoom
  * `images[]` for the full gallery

  Simple example: if your custom storefront currently renders the PDP hero from `cdn_image_url`, switch that hero to `detail_image_url` to get the higher-resolution image.
</Note>

**Example: product grid**

```javascript theme={"system"}
async function renderProducts() {
  const { data: products } = await shoppex.getProducts();
  const container = document.getElementById('products');

  container.innerHTML = products.map(product => `
    <div class="product-card">
      <img src="${product.cdn_image_url || product.images[0]?.url || ''}" alt="${product.title}" style="border-radius: 8px;">
      <h3>${product.title}</h3>
      <span class="price">
        ${shoppex.formatPrice(product.price, product.currency)}
      </span>
      ${product.stock < 10 ? '<span class="low-stock">Only ' + product.stock + ' left!</span>' : ''}
    </div>
  `).join('');
}
```

### getProduct

Fetches a single product by ID or slug.

```javascript theme={"system"}
const { data: product } = await shoppex.getProduct('prod_abc123');

console.log(product.title);
console.log(product.variants);
```

**Parameters**

<ParamField path="idOrSlug" type="string" required>
  Product unique ID or URL slug
</ParamField>

**Example: product detail page**

```javascript theme={"system"}
async function renderProductDetail(productId) {
  const { data: product } = await shoppex.getProduct(productId);
  const galleryImages = product.images?.length
    ? product.images
    : [{ url: product.detail_image_url || product.cdn_image_url, alt: product.title }].filter(img => img.url);

  document.getElementById('product-detail').innerHTML = `
    <div class="gallery">
      ${galleryImages.map(img => `<img src="${img.url}" alt="${img.alt || product.title}" style="border-radius: 8px;">`).join('')}
    </div>
    <div class="info">
      <h1>${product.title}</h1>
      <p class="price">${shoppex.formatPrice(product.price, product.currency)}</p>
      <div class="description">${product.description}</div>

      ${product.variants?.length ? `
        <select id="variant-select">
          ${product.variants.map(v => `
            <option value="${v.id}">${v.title}</option>
          `).join('')}
        </select>
      ` : ''}

      ${product.video_link ? `
        <div class="product-video">
          <iframe src="${product.video_link}" title="${product.title}" allowfullscreen></iframe>
        </div>
      ` : ''}

      <button onclick="addToCart('${product.uniqid}')">Add to Cart</button>
    </div>
  `;
}
```

### getCategories

Fetches all unique product category IDs from your store.

```javascript theme={"system"}
const { data: categories } = await shoppex.getCategories();

// Returns array of category uniqids (strings)
// ["cat_abc123", "cat_def456", "cat_ghi789"]
```

**Example: category filter**

```javascript theme={"system"}
async function renderCategoryFilter() {
  const { data: categories } = await shoppex.getCategories();

  document.getElementById('category-filter').innerHTML = `
    <select onchange="filterByCategory(this.value)">
      <option value="">All Categories</option>
      ${categories.map(cat => `
        <option value="${cat}">${cat}</option>
      `).join('')}
    </select>
  `;
}

async function filterByCategory(category) {
  const { data: products } = await shoppex.getProducts();

  const filtered = category
    ? products.filter(p => p.categories?.includes(category))
    : products;

  renderProducts(filtered);
}
```

### Product groups

Stores can organize products into groups (for example, "Server Boosts", "Tokens"). Groups come from `getStorefront()`.

```javascript theme={"system"}
const { data: storefront } = await shoppex.getStorefront();

storefront.groups.forEach(group => {
  console.log(group.title, group.products_count);
});
```

<Warning>
  Breaking change in `@shoppexio/storefront` 1.0.0 (and the underlying storefront API): groups no longer embed full product objects in `products_bound`. Each group now carries `product_uniqids`, an array of product references, and every public product, standalone and group-bound, appears exactly once in the flat products list. If your integration reads `group.products_bound`, it sees `undefined` and must migrate to the lookup pattern below.
</Warning>

**Group object**

<ResponseField name="uniqid" type="string">
  Unique group identifier
</ResponseField>

<ResponseField name="title" type="string">
  Group name
</ResponseField>

<ResponseField name="product_uniqids" type="string[]">
  References to the group's products. Resolve them against the flat products list. The full product objects are not embedded in the group.
</ResponseField>

<ResponseField name="products_count" type="number">
  Number of products in the group
</ResponseField>

<ResponseField name="sort_priority" type="number">
  Display order of the group
</ResponseField>

**Resolving group products**

Build a lookup from the flat products list and resolve each group's references.

```javascript theme={"system"}
const { data: storefront } = await shoppex.getStorefront();

const byUniqid = new Map(storefront.products.map(p => [p.uniqid, p]));

storefront.groups.forEach(group => {
  const groupProducts = (group.product_uniqids ?? [])
    .map(uniqid => byUniqid.get(uniqid))
    .filter(Boolean);

  console.log(group.title, groupProducts.map(p => p.title));
});
```

If you install the SDK from npm, the same resolution is available as a named import.

```javascript theme={"system"}
import { getStorefrontGroupProducts } from '@shoppexio/storefront';

const groupProducts = getStorefrontGroupProducts(group, storefront.products);
```

<Note>
  Migrating from 0.3.x? Replace every `group.products_bound` read with the lookup above. `getProducts()` now returns the complete flat catalog, group-bound products included, so you no longer need to merge group products into your listing yourself. If you call the REST API directly, `/v1/storefront/products/public/:slug` groups carry `product_uniqids`. The `/v1/storefront/products/shop/:name` endpoint no longer returns groups at all. Read groups from the public catalog or bootstrap payload instead.
</Note>

### Working with variants

Products can have multiple variant types.

**Standard variants**

Variants such as size or color that do not change the price.

```javascript theme={"system"}
const product = await shoppex.getProduct('prod_abc');

product.variants.forEach(variant => {
  console.log(variant.id, variant.title);
  // "var_1", "Small"
  // "var_2", "Medium"
  // "var_3", "Large"
});
```

**Price variants**

Variants that have different prices.

```javascript theme={"system"}
product.price_variants.forEach(pv => {
  console.log(pv.id, pv.label, pv.price);
  // "pv_1", "Basic", 9.99
  // "pv_2", "Pro", 29.99
  // "pv_3", "Enterprise", 99.99
});
```

**Addons**

Optional extras the customer can add.

```javascript theme={"system"}
product.addons.forEach(addon => {
  console.log(addon.id, addon.name, addon.price, addon.required);
  // "addon_1", "Priority Support", 5.00, false
  // "addon_2", "Extended Warranty", 10.00, false
});
```

### Products error handling

```javascript theme={"system"}
const { data, success, message } = await shoppex.getProduct('invalid-id');

if (!success) {
  console.error('Product not found:', message);
  // Show 404 page or redirect
}
```

## Cart

The Cart object manages shopping cart state in the browser's localStorage. Cart data stays saved across page refreshes and browser sessions.

<Note>
  Cart data is stored in the browser's `localStorage`. If the user clears browser storage, the cart is lost.
</Note>

### getCart

Returns all items currently in the cart.

```javascript theme={"system"}
const cartItems = shoppex.getCart();

cartItems.forEach(item => {
  console.log(item.product_id, item.quantity);
});
```

**Response**

<ResponseField name="return" type="CartItem[]">
  <Expandable title="CartItem object">
    <ResponseField name="line_id" type="string">
      Stable identifier for this cart configuration
    </ResponseField>

    <ResponseField name="product_id" type="string">
      Product unique identifier
    </ResponseField>

    <ResponseField name="variant_id" type="string">
      Selected variant ID (empty string if no variant)
    </ResponseField>

    <ResponseField name="quantity" type="number">
      Item quantity
    </ResponseField>

    <ResponseField name="addons" type="CartAddon[]">
      Selected add-ons
    </ResponseField>

    <ResponseField name="custom_fields" type="Record<string, string>">
      Custom field values
    </ResponseField>

    <ResponseField name="price_variant_id" type="string">
      Selected price variant ID
    </ResponseField>
  </Expandable>
</ResponseField>

### getCartItemCount

Returns the total number of items in the cart.

```javascript theme={"system"}
const count = shoppex.getCartItemCount();
document.getElementById('cart-badge').textContent = count;
```

### addToCart

Adds an item to the cart, or increments quantity if it already exists.

```javascript theme={"system"}
// Basic usage
shoppex.addToCart('prod_abc123', 'var_001', 1);

// With options
shoppex.addToCart('prod_abc123', 'var_001', 2, {
  addons: [{ id: 'addon_1', quantity: 1 }],
  custom_fields: { 'License Name': 'John Doe' },
  price_variant_id: 'pv_pro'
});
```

**Parameters**

<ParamField path="productId" type="string" required>
  Product unique identifier
</ParamField>

<ParamField path="variantId" type="string" required>
  Variant ID. Use empty string `''` for products without variants.
</ParamField>

<ParamField path="quantity" type="number" default="1">
  Number of items to add
</ParamField>

<ParamField path="options" type="CartAddOptions">
  <Expandable title="CartAddOptions">
    <ParamField path="addons" type="CartAddon[]">
      Add-ons to include
    </ParamField>

    <ParamField path="custom_fields" type="Record<string, string>">
      Custom field values
    </ParamField>

    <ParamField path="price_variant_id" type="string">
      Selected price variant
    </ParamField>
  </Expandable>
</ParamField>

**Example: add to cart button**

```javascript theme={"system"}
function handleAddToCart(productId, variantId) {
  const quantity = parseInt(document.getElementById('quantity').value) || 1;

  // Collect selected addons
  const addons = [];
  document.querySelectorAll('.addon-checkbox:checked').forEach(cb => {
    addons.push({ id: cb.value, quantity: 1 });
  });

  shoppex.addToCart(productId, variantId, quantity, { addons });

  // Update UI
  updateCartBadge();
  showNotification('Added to cart!');
}
```

### updateCartItem

Updates an existing cart item.

```javascript theme={"system"}
const lineId = shoppex.resolveCartLineId('prod_abc123', 'var_001');
shoppex.updateCartItem(lineId, {
  quantity: 5,
  addons: [{ id: 'addon_2', quantity: 1 }]
});
```

**Parameters**

<ParamField path="lineId" type="string" required>
  Cart line identifier. Read it from `getCart()` or use `resolveCartLineId()`.
</ParamField>

<ParamField path="updates" type="object" required>
  <Expandable title="Update fields">
    <ParamField path="quantity" type="number">
      New quantity
    </ParamField>

    <ParamField path="addons" type="CartAddon[]">
      Updated add-ons
    </ParamField>

    <ParamField path="custom_fields" type="Record<string, string>">
      Updated custom fields
    </ParamField>
  </Expandable>
</ParamField>

### removeFromCart

Removes an item from the cart.

```javascript theme={"system"}
const lineId = shoppex.resolveCartLineId('prod_abc123', 'var_001');
shoppex.removeFromCart(lineId);
```

**Parameters**

<ParamField path="lineId" type="string" required>
  Cart line identifier. Read it from `getCart()` or use `resolveCartLineId()`.
</ParamField>

### clearCart

Removes all items from the cart.

```javascript theme={"system"}
shoppex.clearCart();
```

### Cart backup

The SDK can back up the cart before checkout, to restore it if checkout is cancelled.

**createCartBackup**

```javascript theme={"system"}
// Called automatically before checkout
shoppex.createCartBackup();
```

**restoreCartFromBackup**

```javascript theme={"system"}
// Restore cart after cancelled checkout
const restored = shoppex.restoreCartFromBackup();

if (restored) {
  console.log('Cart restored');
} else {
  console.log('No backup available');
}
```

### Complete cart UI example

```html theme={"system"}
<div id="cart">
  <h2>Shopping Cart</h2>
  <div id="cart-items"></div>
  <div id="cart-total"></div>
  <button onclick="goToCheckout()">Checkout</button>
</div>

<script>
async function renderCart() {
  const items = shoppex.getCart();
  const container = document.getElementById('cart-items');

  if (items.length === 0) {
    container.innerHTML = '<p>Your cart is empty</p>';
    return;
  }

  // Fetch product details for display
  const { data: products } = await shoppex.getProducts();
  const productMap = new Map(products.map(p => [p.uniqid, p]));

  let total = 0;

  container.innerHTML = items.map(item => {
    const product = productMap.get(item.product_id);
    if (!product) return '';

    const itemTotal = product.price * item.quantity;
    total += itemTotal;

    return `
      <div class="cart-item">
        <img src="${product.images[0]?.url}" alt="${product.title}" style="border-radius: 8px;">
        <div class="item-info">
          <h4>${product.title}</h4>
          <p>${shoppex.formatPrice(product.price, product.currency)}</p>
        </div>
        <div class="quantity">
          <button onclick="updateQuantity('${item.line_id}', ${item.quantity - 1})">-</button>
          <span>${item.quantity}</span>
          <button onclick="updateQuantity('${item.line_id}', ${item.quantity + 1})">+</button>
        </div>
        <button onclick="removeItem('${item.line_id}')" class="remove">Remove</button>
      </div>
    `;
  }).join('');

  document.getElementById('cart-total').innerHTML = `
    <strong>Total: ${shoppex.formatPrice(total, products[0]?.currency || 'USD')}</strong>
  `;
}

function updateQuantity(lineId, newQuantity) {
  if (newQuantity < 1) {
    shoppex.removeFromCart(lineId);
  } else {
    shoppex.updateCartItem(lineId, { quantity: newQuantity });
  }
  renderCart();
}

function removeItem(lineId) {
  shoppex.removeFromCart(lineId);
  renderCart();
}

renderCart();
</script>
```

## Checkout

The Checkout object redirects customers to Shoppex hosted checkout. Checkout is fully hosted by Shoppex for PCI compliance.

<Note>
  Current Storefront SDK versions automatically send a unique
  `X-Idempotency-Key` for each checkout action and retain it when an ambiguous
  transport failure is retried. If you call
  `POST /v1/storefront/invoices/from-cart` directly, send a non-blank key of at
  most 128 characters, reuse it only for an identical retry, and create a new
  key when the cart or checkout intent changes. Missing and oversized keys
  return `errors.checkout.idempotency_key_required` and
  `errors.checkout.idempotency_key_too_long`.
</Note>

### checkout

Redirects the customer to the checkout page with their cart contents.

```javascript theme={"system"}
await shoppex.checkout();
```

**Parameters**

<ParamField path="coupon" type="string">
  Pre-applied coupon code
</ParamField>

<ParamField path="options" type="CheckoutOptions">
  <Expandable title="CheckoutOptions">
    <ParamField path="autoRedirect" type="boolean" default="true">
      Automatically redirect to checkout page
    </ParamField>

    <ParamField path="locale" type="string">
      Override checkout language
    </ParamField>

    <ParamField path="currency" type="string">
      Buyer-selected three-letter currency code, for example `EUR`. Use the
      same currency that was passed to `quoteCart()`.
    </ParamField>
  </Expandable>
</ParamField>

**Example: checkout with coupon**

```javascript theme={"system"}
const couponCode = document.getElementById('coupon-input').value;

if (couponCode) {
  // Validate coupon first
  const { data } = await shoppex.validateCoupon(couponCode);

  if (!data.valid) {
    alert('Invalid coupon code');
    return;
  }
}

// Proceed to checkout
await shoppex.checkout(couponCode);
```

### buildCheckoutUrl

Builds the checkout URL without redirecting. Use this to open checkout in a new tab or iframe.

```javascript theme={"system"}
const selectedCurrency = 'EUR';
const coupon = 'SAVE10';

// The quote and invoice must use the same buyer-selected currency.
await shoppex.quoteCart(coupon, selectedCurrency);

const checkoutUrl = await shoppex.buildCheckoutUrl({
  coupon,
  currency: selectedCurrency,
  locale: 'de',
  redirectUrl: 'https://partner.example/orders/complete',
});
console.log(checkoutUrl);
// https://checkout.shoppex.io/invoice/inv_abc123?locale=de
```

**Parameters**

<ParamField path="coupon" type="string">
  Coupon code to pre-apply
</ParamField>

<ParamField path="locale" type="string">
  Checkout language (for example, 'en', 'de', 'fr')
</ParamField>

<ParamField path="currency" type="string">
  Buyer-selected three-letter currency code, for example `EUR`
</ParamField>

<ParamField path="redirectUrl" type="string">
  Absolute HTTPS destination after a completed purchase. Use a different URL for each sales channel. A product-level redirect takes precedence.
</ParamField>

<Warning>
  On a multi-currency storefront, pass the same selected currency to
  `quoteCart()` and `buildCheckoutUrl()`. A quote token is bound to its
  currency. If checkout submits another currency, Shoppex rejects it with
  `errors.checkout.quote_token_stale` instead of creating an invoice for a
  total the buyer did not approve. Re-quote after a currency change before
  starting checkout.
</Warning>

**Example: open checkout in new tab**

```javascript theme={"system"}
async function openCheckoutInNewTab() {
  const url = await shoppex.buildCheckoutUrl();
  window.open(url, '_blank');
}
```

### buildCheckoutUrlSync (deprecated)

<Note>
  `buildCheckoutUrlSync` is deprecated and throws immediately. Use `buildCheckoutUrl()` instead. It handles both default and custom domains.
</Note>

### Coupons

**validateCoupon**

Validates a coupon code before checkout. Affiliate and referral codes are separate from coupons. Use `validateAffiliateCode` or `applyAffiliateCode` for those instead.

```javascript theme={"system"}
const { data } = await shoppex.validateCoupon('SAVE10', {
  productId: 'prod_abc123',
  variantId: 'variant_lifetime',
});

if (data.valid) {
  console.log('Discount:', data.discount, data.discount_type);
  // 10, "percentage" → 10% off
  // 5.00, "fixed" → $5 off
} else {
  console.log('Invalid or expired coupon');
}
```

**Parameters**

<ParamField path="code" type="string" required>
  Coupon code to validate
</ParamField>

<ParamField path="options.productId" type="string">
  Product ID to check product-specific coupons
</ParamField>

<ParamField path="options.variantId" type="string">
  Selected variant ID to check variant-specific coupons. Requires `options.productId`.
</ParamField>

**Response**

<ResponseField name="data" type="CouponValidation">
  <Expandable title="CouponValidation">
    <ResponseField name="valid" type="boolean">
      Whether the coupon is valid
    </ResponseField>

    <ResponseField name="discount" type="number">
      Discount amount
    </ResponseField>

    <ResponseField name="discount_type" type="string">
      Either "percentage" or "fixed"
    </ResponseField>

    <ResponseField name="product_restricted" type="boolean">
      Whether the coupon is restricted to selected products
    </ResponseField>

    <ResponseField name="variant_restricted" type="boolean">
      Whether the coupon is restricted to selected variants
    </ResponseField>

    <ResponseField name="restriction_scope" type="string">
      One of "all", "products", "variants", or "products\_and\_variants"
    </ResponseField>

    <ResponseField name="allowed_product_ids" type="string[]">
      Public product IDs the coupon can apply to
    </ResponseField>

    <ResponseField name="allowed_variant_ids" type="string[]">
      Variant IDs the coupon can apply to
    </ResponseField>
  </Expandable>
</ResponseField>

### Affiliate codes

**captureAffiliateFromUrl**

Reads `?ref=<code>` from the current URL and stores the normalized code for 30 days (last click wins). `checkout()` and `quoteCart()` pick the stored code up automatically, so this one call is what makes affiliate links like `https://mystore.com/product?ref=creator10` actually apply the discount and credit the affiliate.

```javascript theme={"system"}
// Call once on every page load, right after shoppex.init(...)
await shoppex.captureAffiliateFromUrl();
```

<Warning>
  If you build a storefront on your own code (instead of starting from an official template), you must make this call yourself on page load. Without it, buyers arriving through affiliate links pay full price and the affiliate is never credited — with no error anywhere. The official code storefront templates already include it.
</Warning>

The call also reports the referral click to the backend for the affiliate's click statistics, and it validates the code — a code the shop refuses is cleared again. A network failure keeps the raw code stored; checkout validates it server-side either way. Pass a custom parameter name to capture something other than `?ref=`: `captureAffiliateFromUrl('via')`.

**validateAffiliateCode**

Validates an affiliate or referral code without storing it.

```javascript theme={"system"}
const result = await shoppex.validateAffiliateCode('creator10');

if (result.success && result.data.valid) {
  console.log(result.data.affiliate_code);
} else if (result.data?.program_enabled === false) {
  console.log('Affiliate program is disabled for this shop');
}
```

**applyAffiliateCode**

Validates and stores the normalized affiliate code. `checkout()` sends the stored code as `affiliate_code`.

```javascript theme={"system"}
const affiliate = await shoppex.applyAffiliateCode('creator10');

await shoppex.checkout({
  coupon: 'SAVE10',
  affiliateCode: affiliate.data?.affiliate_code,
});
```

**getAffiliateCode / clearAffiliateCode**

`getAffiliateCode()` returns the currently stored (non-expired) affiliate code or `null` — useful for showing a "referral applied" badge. `clearAffiliateCode()` removes it.

**Example: coupon input with validation**

```html theme={"system"}
<div class="coupon-form">
  <input type="text" id="coupon-input" placeholder="Enter coupon code">
  <button onclick="applyCoupon()">Apply</button>
  <span id="coupon-status"></span>
</div>

<script>
let appliedCoupon = null;

async function applyCoupon() {
  const code = document.getElementById('coupon-input').value.trim();
  const status = document.getElementById('coupon-status');

  if (!code) {
    status.textContent = '';
    return;
  }

  const { data } = await shoppex.validateCoupon(code);

  if (data.valid) {
    appliedCoupon = code;
    if (data.discount_type === 'percentage') {
      status.textContent = `${data.discount}% off applied!`;
    } else {
      status.textContent = `$${data.discount} off applied!`;
    }
    status.className = 'success';
  } else {
    appliedCoupon = null;
    status.textContent = 'Invalid coupon';
    status.className = 'error';
  }
}

async function goToCheckout() {
  await shoppex.checkout(appliedCoupon);
}
</script>
```

### Checkout flow

```
┌─────────────────────────────────────────────────────────────────┐
│                        Your Website                             │
├─────────────────────────────────────────────────────────────────┤
│  1. Customer browses products                                   │
│  2. Adds items to cart (SDK stores in localStorage)            │
│  3. Clicks "Checkout"                                          │
│  4. SDK calls shoppex.checkout()                      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Shoppex Checkout                            │
│                   (yourstore.shoppex.io)                        │
├─────────────────────────────────────────────────────────────────┤
│  5. Customer enters email, billing info                        │
│  6. Selects payment method (Stripe, PayPal, Crypto)           │
│  7. Completes payment                                          │
│  8. Receives confirmation + delivery                           │
└─────────────────────────────────────────────────────────────────┘
```

### After checkout

After successful checkout, the cart is automatically cleared. If you need to handle the return, read the order ID from the URL.

```javascript theme={"system"}
// Check URL for order confirmation
const urlParams = new URLSearchParams(window.location.search);
const orderId = urlParams.get('order');

if (orderId) {
  // Customer returned from successful checkout
  showOrderConfirmation(orderId);
}
```

## Reviews and invoices

This section documents two separate objects: shop reviews and invoice status.

### getShopReviews

Fetches all public reviews for the store.

```javascript theme={"system"}
const { data: reviews } = await shoppex.getShopReviews();

reviews.forEach(review => {
  console.log(review.rating, review.comment);
});
```

**Response**

<ResponseField name="data" type="Feedback[]">
  <Expandable title="Feedback object">
    <ResponseField name="id" type="string">
      Review unique identifier
    </ResponseField>

    <ResponseField name="rating" type="number">
      Star rating (1-5)
    </ResponseField>

    <ResponseField name="comment" type="string">
      Review text
    </ResponseField>

    <ResponseField name="created_at" type="string">
      ISO timestamp
    </ResponseField>
  </Expandable>
</ResponseField>

**Example: reviews section**

```javascript theme={"system"}
async function renderReviews() {
  const { data: reviews } = await shoppex.getShopReviews();
  const container = document.getElementById('reviews');

  // Calculate average
  const avgRating = reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;

  container.innerHTML = `
    <div class="reviews-header">
      <h2>Customer Reviews</h2>
      <div class="average">
        ${'★'.repeat(Math.round(avgRating))}${'☆'.repeat(5 - Math.round(avgRating))}
        <span>${avgRating.toFixed(1)} / 5</span>
        <span>(${reviews.length} reviews)</span>
      </div>
    </div>

    <div class="reviews-list">
      ${reviews.map(review => `
        <div class="review">
          <div class="stars">${'★'.repeat(review.rating)}${'☆'.repeat(5 - review.rating)}</div>
          <p class="comment">${review.comment || ''}</p>
          <span class="date">${new Date(review.created_at).toLocaleDateString()}</span>
        </div>
      `).join('')}
    </div>
  `;
}
```

### getInvoice

Fetches full invoice details.

```javascript theme={"system"}
const { data: invoice } = await shoppex.getInvoice('inv_abc123');

console.log(invoice.status);   // "COMPLETED"
console.log(invoice.total);    // 29.99
console.log(invoice.products); // [{ title, quantity, price }]
```

**Response**

<ResponseField name="data" type="Invoice">
  <Expandable title="Invoice object">
    <ResponseField name="uniqid" type="string">
      Invoice unique identifier
    </ResponseField>

    <ResponseField name="status" type="string">
      Invoice status (PENDING, COMPLETED, CANCELLED, and more)
    </ResponseField>

    <ResponseField name="total" type="number">
      Total amount
    </ResponseField>

    <ResponseField name="currency" type="string">
      Currency code
    </ResponseField>

    <ResponseField name="products" type="InvoiceProduct[]">
      Purchased products
    </ResponseField>
  </Expandable>
</ResponseField>

### getInvoiceStatus

Lightweight endpoint for status polling. Use this instead of `getInvoice` for real-time updates.

```javascript theme={"system"}
const { data } = await shoppex.getInvoiceStatus('inv_abc123');
console.log(data.status); // "PENDING" | "COMPLETED" | "CANCELLED"
```

**Example: order status page**

```javascript theme={"system"}
async function pollOrderStatus(invoiceId) {
  const statusEl = document.getElementById('order-status');

  const checkStatus = async () => {
    const { data } = await shoppex.getInvoiceStatus(invoiceId);

    switch (data.status) {
      case 'PENDING':
        statusEl.innerHTML = '<span class="pending">Waiting for payment...</span>';
        break;
      case 'PROCESSING':
        statusEl.innerHTML = '<span class="processing">Processing payment...</span>';
        break;
      case 'COMPLETED':
        statusEl.innerHTML = '<span class="success">Order complete! Check your email.</span>';
        clearInterval(pollInterval);
        break;
      case 'CANCELLED':
        statusEl.innerHTML = '<span class="error">Order cancelled</span>';
        clearInterval(pollInterval);
        break;
    }
  };

  // Poll every 5 seconds
  const pollInterval = setInterval(checkStatus, 5000);
  checkStatus(); // Initial check
}

// Get invoice ID from URL
const invoiceId = new URLSearchParams(window.location.search).get('invoice');
if (invoiceId) {
  pollOrderStatus(invoiceId);
}
```

### Formatting utilities

**formatPrice**

Formats a price with a currency symbol.

```javascript theme={"system"}
shoppex.formatPrice(29.99, 'USD');       // "$29.99"
shoppex.formatPrice(29.99, 'EUR');       // "€29.99"
shoppex.formatPrice(29.99, 'EUR', 'de'); // "29,99 €"
```

**Parameters**

<ParamField path="amount" type="number" required>
  Price amount
</ParamField>

<ParamField path="currency" type="string" default="Store currency">
  ISO 4217 currency code
</ParamField>

<ParamField path="locale" type="string" default="en">
  Locale for formatting
</ParamField>

**createFormatter**

Creates a reusable `Intl.NumberFormat` instance.

```javascript theme={"system"}
const formatter = shoppex.createFormatter('EUR', 'de');

formatter.format(29.99);  // "29,99 €"
formatter.format(100);    // "100,00 €"
```

## Customer account

Calls for a signed-in customer: sign-in, order history, licenses, subscriptions, support and profile. They work only on a storefront served on the merchant's own domain by Shoppex, because that is where the session lives.

The session is an HttpOnly cookie set by the Shoppex edge, and the shop is derived from the host. So none of these calls take or return a session token, and none of them take a shop. Do not send either.

| Call                                                                                             | Endpoint                                                    | Purpose                                                   |
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | --------------------------------------------------------- |
| `requestOtp(email)`                                                                              | `POST /api/customer/auth/otp/request`                       | Email a single-use sign-in code                           |
| `verifyOtp(email, otp)`                                                                          | `POST /api/customer/auth/otp/verify`                        | Complete sign-in. The cookie arrives with the response    |
| `logout()`                                                                                       | `POST /api/customer/auth/logout`                            | End the session                                           |
| `me()`                                                                                           | `GET /api/customer/me`                                      | Who is signed in, if anyone                               |
| `dashboard()`                                                                                    | `GET /api/customer/dashboard`                               | Overview: customer, counters, store credit, recent orders |
| `orders({ page, limit })`                                                                        | `GET /api/customer/invoices`                                | Order history, one page at a time                         |
| `order(uniqid)`                                                                                  | `GET /api/customer/invoice/<id>`                            | One order with line items, delivery and payment           |
| `resetLicenseHwid(uniqid)`                                                                       | `POST /api/customer/licenses/<id>/reset-hwid`               | Unbind a licence from its device                          |
| `subscriptionBillingHistory(uniqid)`                                                             | `GET /api/customer/subscriptions/<id>/billing-history`      | Past charges for one subscription                         |
| `cancelSubscription(uniqid, options)`, `pauseSubscription(uniqid)`, `resumeSubscription(uniqid)` | `POST /api/customer/subscriptions/<id>/…`                   | Change a subscription's standing                          |
| `favorites()`, `addFavorite(productUniqid)`, `removeFavorite(productUniqid)`                     | `GET`, `PUT` and `DELETE /api/customer/favorites`           | Saved products. Adding is a `PUT`, not a `POST`           |
| `affiliate()`                                                                                    | `GET /api/customer/affiliate`                               | Referral summary                                          |
| `createTicket(payload)`, `ticket(uniqid)`, `replyToTicket(uniqid, message)`                      | `POST` and `GET /api/customer/tickets…`                     | Support conversations                                     |
| `updateProfile({ name })`                                                                        | `POST /api/customer/profile`                                | Change the display name                                   |
| `updateAvatar(file)`, `removeAvatar()`                                                           | `POST` and `DELETE /api/customer/profile/avatar`            | Profile photo                                             |
| `emailPreferences()`, `updateEmailPreferences(patch)`                                            | `GET` and `PATCH /api/customer/email-marketing/preferences` | Marketing opt-in and per-list subscriptions               |
| `sessions()`, `revokeSession(id)`, `revokeAllSessions()`                                         | `GET` and `DELETE /api/customer/sessions`                   | Signed-in devices                                         |

<Note>
  **Licences, subscriptions and tickets have no list call.** Those three
  collections arrive inside `dashboard()`; the endpoints above act on one item
  you already have an id for. A `GET /api/customer/licenses` does not exist and
  answers `404` at the edge.
</Note>

```javascript theme={"system"}
await shoppex.requestOtp('buyer@example.com');

const signedIn = await shoppex.verifyOtp('buyer@example.com', '123456');
if (signedIn.success) {
  const { data } = await shoppex.dashboard();
  console.log(data.customer.email, data.stats.invoices_count);
}
```

Order history is page based. `has_more` says whether a next page exists; there is no cursor.

```javascript theme={"system"}
const { data } = await shoppex.orders({ page: 1, limit: 10 });

data.invoices.forEach((order) => console.log(order.uniqid, order.status));
console.log(data.pagination.has_more);
```

<Note>
  The endpoint list above is complete. Endpoints outside it, including license renewal, affiliate payouts, the subscription payment-method portal, reseller surfaces and wallet transfers, answer `404` at the edge. Link customers to the hosted flow for those.
</Note>

Building the account pages into a code storefront, including routing and the states to handle: [Customer accounts](/storefront/code-storefront-accounts).

## Types

The SDK is written in TypeScript and exports all type definitions. Install with npm to get full IntelliSense support.

### Configuration types

```typescript theme={"system"}
interface ShoppexConfig {
  storeSlug: string;
  locale?: string;
  currency?: string;
  apiBaseUrl?: string;
}

interface ShoppexInitOptions {
  locale?: string;
  currency?: string;
  apiBaseUrl?: string;
}
```

### Response types

```typescript theme={"system"}
interface SDKResponse<T> {
  success: boolean;
  data?: T;
  message?: string;
}
```

### Store types

```typescript theme={"system"}
interface Shop {
  id: string;
  name: string;
  slug: string;
  domain?: string;
  description?: string;
  currency: string;
  logo?: string;
  banner?: string;
  rating?: number;
  tos_enabled?: boolean;
}
```

### Product types

```typescript theme={"system"}
interface ProductCategory {
  uniqid: string;
  title: string;
}

interface Product {
  uniqid: string;
  title: string;
  slug?: string;
  description?: string;
  price: string;           // String for precision
  price_display?: string;  // Formatted display price
  currency: string;
  stock?: number;
  cdn_image_url?: string | null;    // Card/listing/search image
  detail_image_url?: string | null; // PDP/gallery/zoom image
  images: ProductImage[];
  variants?: ProductVariant[];
  addons?: ProductAddon[];
  price_variants?: PriceVariant[];
  custom_fields?: string | unknown[] | null;
  categories?: ProductCategory[];
}

interface ProductImage {
  id: string;
  url: string;
  cloudflare_image_id?: string;
  alt?: string;
}

interface ProductVariant {
  id: string;
  title: string;
  price?: number;
  stock?: number;
  is_featured?: boolean;
}

interface ProductAddon {
  id: string;
  name: string;
  price: number;
  required?: boolean;
}

interface PriceVariant {
  id: string;
  label: string;
  price: number;
  is_featured?: boolean;
}

interface CustomFieldDefinition {
  id: string;
  name: string;
  type: 'text' | 'textarea' | 'select' | 'checkbox';
  required?: boolean;
  options?: string[];
}
```

`is_featured` on `ProductVariant` and `PriceVariant` marks the one variant the merchant wants to present as “Most Popular.” At most one variant per product carries it. It does not change the selected variant, price, stock, or checkout behavior.

<Note>
  Image fields have different jobs:

  * `cdn_image_url` is the optimized storefront cover for cards and lists
  * `detail_image_url` is the higher-resolution primary image for product detail pages
  * `images[]` contains the gallery

  If you run a headless storefront, keep cards on `cdn_image_url` and switch your PDP hero and gallery to `detail_image_url`.
</Note>

### Cart types

```typescript theme={"system"}
interface CartItem {
  line_id: string;
  product_id: string;
  variant_id: string;
  quantity: number;
  addons?: CartAddon[];
  custom_fields?: Record<string, string>;
  price_variant_id?: string;
}

interface CartAddon {
  id: string;
  quantity?: number;
}

interface CartAddOptions {
  addons?: CartAddon[];
  custom_fields?: Record<string, string>;
  price_variant_id?: string;
}

interface CartUpdateOptions {
  quantity?: number;
  addons?: CartAddon[];
  custom_fields?: Record<string, string>;
  price_variant_id?: string;
}
```

### Checkout types

```typescript theme={"system"}
interface CheckoutOptions {
  autoRedirect?: boolean;
  locale?: string;
  email?: string;
  coupon?: string;
  currency?: string;
  redirectUrl?: string;
  affiliateCode?: string;
  referralCode?: string;
  customerCheckoutPath?: '/dashboard/api/checkout';
  expectedCart?: string;
}

interface CheckoutResult {
  success: boolean;
  redirectUrl?: string;
  invoiceId?: string;
  message?: string;
  code?: string;
}

interface CouponValidation {
  valid: boolean;
  discount?: number;
  discount_type?: 'percentage' | 'fixed';
  message?: string;
}
```

### Invoice types

```typescript theme={"system"}
interface Invoice {
  uniqid: string;
  status: string;
  total: number;
  currency: string;
  gateway?: string;
  products: InvoiceProduct[];
  created_at: string;
}

interface InvoiceProduct {
  product_id: string;
  title: string;
  quantity: number;
  price: number;
}
```

### Review types

```typescript theme={"system"}
interface Feedback {
  id: string;
  rating: number;
  comment?: string;
  created_at: string;
}
```

### Error types

```typescript theme={"system"}
class ShoppexError extends Error {
  readonly code: string;
  readonly statusCode?: number;
}

class CheckoutCreateError extends Error {
  readonly code?: string;
  readonly status?: number;
}

class NotInitializedError extends ShoppexError {
  // Thrown when SDK methods are called before init()
  // code: 'NOT_INITIALIZED'
}

class NetworkError extends ShoppexError {
  // Thrown on HTTP/network failures
  // code: 'NETWORK_ERROR'
}

class ValidationError extends ShoppexError {
  // Thrown on input validation failures
  // code: 'VALIDATION_ERROR'
  readonly invalidFields?: string[];
}

class CartError extends ShoppexError {
  // Thrown on cart operation errors
  // code: 'BASKET_ERROR'
}
```

### Usage with TypeScript

```typescript theme={"system"}
import shoppex, {
  type Product,
  type ProductVariant,
  type CartItem,
  type Invoice,
  type SDKResponse
} from '@shoppexio/storefront';

// Initialize
shoppex.init('my-store');

// Typed responses
async function loadProducts(): Promise<Product[]> {
  const response: SDKResponse<Product[]> = await shoppex.getProducts();

  if (!response.success || !response.data) {
    throw new Error(response.message || 'Failed to load products');
  }

  return response.data;
}

// Type-safe cart operations
function addProductToCart(product: Product, variant?: ProductVariant): void {
  shoppex.addToCart(
    product.uniqid,
    variant?.id ?? '',
    1
  );
}

// Typed invoice handling
async function checkOrderStatus(invoiceId: string): Promise<string> {
  const { data } = await shoppex.getInvoiceStatus(invoiceId);
  return data.status;
}
```
