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

# Headless customer portal

> Build a complete custom customer portal with Shoppex login, orders, delivery, support, account settings, subscriptions, licenses, and benefits.

Use `@shoppexio/storefront/customer` when your storefront runs on your own domain and you want to build the customer portal yourself. Shoppex handles email OTP login, tenant isolation, customer data, and buyer actions. Your frontend owns the UI.

<Note>
  Headless customer accounts are available on every plan as part of the Storefront SDK. They reuse the same publishable key and allowed origins as [Headless Checkout](/developers/headless/checkout), while creating Headless Checkout sessions still requires Business.
</Note>

## Setup

Open **Dashboard -> Settings -> Developer -> Headless**. Generate a publishable key and add the exact origin that hosts the customer page, for example `https://yourstore.com`.

```bash theme={"system"}
npm install @shoppexio/storefront
```

```ts theme={"system"}
import { createCustomerClient } from '@shoppexio/storefront/customer';

const customer = createCustomerClient({
  shop: 'your-shop-slug',
  publishableKey: 'pk_live_your_key',
});
```

The publishable key is browser-safe. Never use a secret `shx_` Developer API key in this client.

## What you can build

The client covers the buyer-safe parts of the Shoppex customer portal:

| Area                | Methods                                                                                                                                                                                                                                  |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Login               | `requestOtp`, `verifyOtp`, `getSession`, `logout`, `clearSession`                                                                                                                                                                        |
| Overview            | `me`, `dashboard`                                                                                                                                                                                                                        |
| Account             | `updateProfile`, `updateAvatar`, `removeAvatar`, `emailPreferences`, `updateEmailPreferences`, `notificationPreferences`, `updateNotificationPreferences`                                                                                |
| Orders              | `orders`, `order`, `invoicePdf`, `download`, `replacementEligibility`, `requestReplacement`                                                                                                                                              |
| Reviews             | `orderReview`, `submitOrderReview`                                                                                                                                                                                                       |
| Support             | `createTicket`, `ticket`, `replyToTicket`                                                                                                                                                                                                |
| Licenses and wallet | `resetLicenseHwid`, `renewLicense`, `walletTopupQuote`, `createWalletTopup`                                                                                                                                                              |
| Subscriptions       | `subscriptionBillingHistory`, `cancelSubscription`, `pauseSubscription`, `resumeSubscription`                                                                                                                                            |
| Benefits            | `loyalty`, `redeemLoyaltyPoints`, `warranties`, `claimWarranty`, `favorites`, `addFavorite`, `removeFavorite`, `affiliate`, `createAffiliateLink`                                                                                        |
| Reseller            | `reseller`, `applyForReseller`, `enrollAsReseller`, `acceptResellerInvite`, `resellerCatalog`, `quoteResellerOrder`, `resellerOrders`, `resellerOrder`, `resellerWallet`, `resellerTopupQuote`, `createResellerTopup`, `resellerApiKeys` |

`dashboard()` is the starting payload for an account home page. It includes recent orders and tickets, licenses, subscriptions, deliverables, wallet state, rewards, affiliate state, and summary counts. Use the focused methods when the buyer opens or changes one area.

## OTP login

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

await customer.verifyOtp('buyer@example.com', '123456', {
  rememberMe: true,
});
```

Normal login is stored in `sessionStorage` and is valid for up to 12 hours. `rememberMe: true` stores it in `localStorage` and keeps the customer signed in for up to 30 days. Both are capped by the underlying Shoppex buyer session. A logout or an unauthorized response removes the saved session.

Use `rememberMe` only after the buyer explicitly selects a **Keep me signed in** option on a private device.

OTP requests use a success-like response for known and unknown email addresses. Do not use the response to decide if a customer account exists.

Check the saved session when your customer page starts:

```ts theme={"system"}
const session = customer.getSession();

if (session) {
  const profile = await customer.me();
  console.log(profile.customer);
} else {
  // Show your OTP login form.
}
```

## Customer and orders

```ts theme={"system"}
const profile = await customer.me();

const firstPage = await customer.orders({
  page: 1,
  limit: 20,
  status: 'COMPLETED',
  search: 'invoice-id',
});

const order = await customer.order('invoice-uuid');
```

The client validates the `me`, order-list, and order-detail response contracts before returning them. Prices remain decimal strings so your UI does not introduce floating-point rounding.

## Order reviews

Load review state when the buyer opens a completed order. Shoppex decides if
that signed-in customer may review the purchase, so render the action from
`can_review` instead of deriving eligibility from the order status yourself.

```ts theme={"system"}
const review = await customer.orderReview(order.uniqid);

if (review.can_review) {
  const saved = await customer.submitOrderReview(order.uniqid, {
    score: 5,
    message: 'Fast delivery and clear instructions.',
  });

  console.log(saved.already_submitted, saved.score);
}
```

`orderReview()` also returns an existing review. `already_submitted`, `score`,
`message`, and `submitted_at` let the customer dashboard show what was already
sent. Reviews are Shoppex shop reviews tied to a verified order, not separate
reviews for every line item. A foreign order ID returns `404`.

Shoppex stores review sentiment in three levels. Send `1` for negative, `3`
for neutral, or `5` for positive. Review messages may contain up to 256 visible
Unicode characters; joined emoji count as one character.

## Account home and settings

Use `dashboard()` for the first account screen. The returned IDs are the inputs for the focused actions below.

```ts theme={"system"}
const dashboard = await customer.dashboard();

console.log(dashboard.invoices);
console.log(dashboard.tickets);
console.log(dashboard.licenses);
console.log(dashboard.subscriptions);
console.log(dashboard.deliverables);
```

File deliverables contain `invoice_uniqid` and `downloadIndex`. Pass both values to
`download()` instead of following a hosted customer-portal URL:

```ts theme={"system"}
const fileDeliverable = dashboard.deliverables.find(
  (item) => item.kind === 'file' && item.downloadIndex !== undefined,
);

if (fileDeliverable?.downloadIndex !== undefined) {
  const file = await customer.download(
    fileDeliverable.invoice_uniqid,
    fileDeliverable.downloadIndex,
  );
}
```

Profile, avatar, marketing preferences, and transactional email preferences are separate calls:

```ts theme={"system"}
await customer.updateProfile({ name: 'Alex Smith' });
await customer.updateAvatar(fileInput.files![0]);
await customer.removeAvatar();

const marketing = await customer.emailPreferences();
await customer.updateEmailPreferences({
  globalUnsubscribed: false,
  listSubscriptions: marketing.lists.map((list) => ({
    listId: list.id,
    subscribed: true,
  })),
});

await customer.updateNotificationPreferences({
  orderCompletedEmails: true,
  paymentIssueEmails: true,
});
```

Only send preference fields that the buyer changed. Show the selected file name and a preview before you call `updateAvatar()`.

## Support tickets

The dashboard response contains the buyer's recent ticket list. Use the ticket ID to open the full conversation and send replies.

```ts theme={"system"}
const created = await customer.createTicket({
  title: 'Order issue',
  message: 'Please help with this order.',
  invoiceId: 'invoice-uuid',
});

const conversation = await customer.ticket(created.uniqid);
await customer.replyToTicket(conversation.ticket.uniqid, 'Here is more information.');
```

`invoiceId` is optional. When you include it, Shoppex verifies that the order belongs to the signed-in buyer.

## Licenses and subscriptions

Use the license and subscription IDs returned by `dashboard()`.

```ts theme={"system"}
const dashboard = await customer.dashboard();
const license = dashboard.licenses[0];
const subscription = dashboard.subscriptions[0];

if (license) {
  await customer.resetLicenseHwid(license.uniqid);

  if (license.renewable) {
    const renewal = await customer.renewLicense(license.uniqid);
    window.location.assign(renewal.checkout_url);
  }
}

if (subscription) {
  const billing = await customer.subscriptionBillingHistory(subscription.uniqid);

  if (subscription.actions.can_pause) {
    await customer.pauseSubscription(subscription.uniqid);
  }

  if (subscription.actions.can_resume) {
    await customer.resumeSubscription(subscription.uniqid);
  }

  if (subscription.actions.can_cancel) {
    await customer.cancelSubscription(subscription.uniqid, {
      cancelAtPeriodEnd: true,
      reason: 'No longer needed',
    });
  }
}
```

Render actions only when the matching `actions.can_*` field is true. Ask for buyer confirmation immediately before a destructive action such as cancellation or hardware reset.

Wallet and license renewal methods create a normal Shoppex checkout. They do not charge the buyer inside the SDK:

```ts theme={"system"}
const quote = await customer.walletTopupQuote();
if (quote.quote.enabled) {
  const topup = await customer.createWalletTopup('25.00');
  window.location.assign(topup.url);
}
```

## Replacements, loyalty, warranties, and favorites

Replacement requests require `lineItemId`, the UUID of a position in the invoice.
The server rejects a missing or invalid position; it never uses the whole invoice
as an implicit refund target. The SDK requires this ID when creating a replacement request.

```ts theme={"system"}
const eligibility = await customer.replacementEligibility('invoice-uuid', 'a2791ea5-1713-4c1e-8e86-490b4332db11');
if (eligibility.eligible) {
  await customer.requestReplacement('invoice-uuid', {
    lineItemId: 'a2791ea5-1713-4c1e-8e86-490b4332db11',
    reason: 'The delivered code does not work',
    idempotencyKey: crypto.randomUUID(),
  });
}

const loyalty = await customer.loyalty();
if (loyalty.account && loyalty.settings) {
  await customer.redeemLoyaltyPoints({
    points: loyalty.settings.min_redeem_points,
    idempotencyKey: crypto.randomUUID(),
  });
}

const warranties = await customer.warranties('invoice-uuid');
const firstWarranty = warranties.warranties[0];
if (firstWarranty) {
  await customer.claimWarranty(firstWarranty.uniqid, 'The product stopped working.');
}

await customer.addFavorite('product-uuid');
await customer.removeFavorite('product-uuid');
```

Generate one idempotency key per buyer action and reuse that key when retrying the same action. A double-click must not create a second redemption or replacement request.

## Affiliate and reseller views

`affiliate()` returns the existing affiliate links, balances, commissions, and payout state. `createAffiliateLink()` creates or requests a custom link. It does not move funds.

```ts theme={"system"}
await customer.createAffiliateLink({
  code: 'alex-store',
  label: 'My store link',
});
```

The reseller methods support enrollment, catalog browsing, server-priced quotes, order history, wallet display, wallet top-up checkout, and API-key summaries. Shoppex keeps reseller order placement and API-key creation or revocation behind protected flows.

```ts theme={"system"}
const state = await customer.reseller();

if (!state.reseller && state.enrollment_mode === 'APPLICATION') {
  await customer.applyForReseller('I sell to local businesses.');
}

const catalog = await customer.resellerCatalog({ page: 1, perPage: 20 });
const firstProduct = catalog.catalog.items[0];
if (firstProduct) {
  const orderQuote = await customer.quoteResellerOrder([
    { productId: firstProduct.product_id, quantity: 2 },
  ]);
  console.log(orderQuote.quote.total);
}

const topupQuote = await customer.resellerTopupQuote();
if (topupQuote.quote.enabled) {
  const topup = await customer.createResellerTopup('100.00');
  window.location.assign(topup.topup.url);
}
```

Use the quote response as the price source. Do not calculate reseller prices from catalog values in the browser.

`orders()` returns summary rows for an order list. Each summary contains at most three line items. Do not use the summary as a full order.

Call `order()` when the buyer opens one order. The detail response contains pricing, payment, line-item, and delivery data.

Use `pagination.has_more` to decide if your UI must show a **Load more** button. The API returns at most 50 orders per page.

### Order detail data

Use decimal strings from the response. Do not convert money to JavaScript floating-point values before calculations.

```ts theme={"system"}
const order = await customer.order('invoice-uuid');

const displayAmount = order.totalDisplay ?? order.total;
const paymentName = order.currentPayment?.displayName
  ?? order.currentPayment?.apmMethod
  ?? (order.currentPayment?.cryptoGateway ? 'Crypto payment' : null);

console.log(displayAmount, order.currency, paymentName);
```

Use `displayName` before `apmMethod`. The `cryptoGateway` field contains the exact coin when both labels are null. Format that coin with your buyer-facing labels. Do not show the raw `gateway` value because it can identify an internal payment rail.

The order detail contains these main groups:

| Data            | Fields                                                                                  |
| --------------- | --------------------------------------------------------------------------------------- |
| Price           | `subtotal`, `discount`, `tax`, `balancePaidAmount`, `total`, `totalDisplay`, `currency` |
| Payment         | `currentPayment`, `payments`, `gatewayUpdatedAt`                                        |
| Items           | `lineItems` with product, variant, quantity, price, and delivery data                   |
| Additional data | `customFields`, `statusDetails`, and order timestamps                                   |

Use `currentPayment` for the active payment method. `payments` contains the same public payment snapshot in array form for existing consumers.

## Delivery and service instructions

Call `order()` before you build the delivery section. The order-list response does not contain delivery data.

Each item in `order.lineItems` contains these delivery fields:

| Field                     | Use                                                                     |
| ------------------------- | ----------------------------------------------------------------------- |
| `deliveryStatus`          | Shows if delivery is pending, in preparation, delivered, or delayed.    |
| `deliveredAt`             | Contains the delivery time after delivery.                              |
| `deliveryText`            | Contains instructions that belong to this delivered order item.         |
| `product.serviceText`     | Contains the default service instructions for the delivered product.    |
| `deliverySummary.codes`   | Contains license keys and other delivered codes.                        |
| `deliverySummary.serials` | Contains serials and replacement state.                                 |
| `deliverySummary.notes`   | Contains delivery notes.                                                |
| `deliverySummary.links`   | Contains external access links or a `downloadIndex` for a Shoppex file. |

Shoppex returns `deliveryText` and `product.serviceText` only after the item is delivered. This rule prevents delivery content from appearing before payment and fulfillment.

Use the order-specific `deliveryText` first. Use the product `serviceText` when the order has no specific instructions.

```ts theme={"system"}
import type { HeadlessCustomerOrder } from '@shoppexio/storefront/customer';

type OrderItem = HeadlessCustomerOrder['lineItems'][number];

function getDeliveryInstructions(item: OrderItem): string | null {
  return item.deliveryText?.trim()
    || item.product?.serviceText?.trim()
    || null;
}

const order = await customer.order('invoice-uuid');

for (const item of order.lineItems) {
  if (item.deliveryStatus === 'AWAITING_FULFILLMENT') {
    console.log(`${item.productTitle}: Being prepared by the seller`);
    continue;
  }

  if (item.deliveryStatus === 'FAILED') {
    console.log(`${item.productTitle}: Delivery is delayed`);
    continue;
  }

  if (item.deliveryStatus !== 'DELIVERED') continue;

  const instructions = getDeliveryInstructions(item);
  console.log(instructions);
  console.log(item.deliverySummary?.codes ?? []);
  console.log(item.deliverySummary?.serials ?? []);
  console.log(item.deliverySummary?.notes ?? []);
  console.log(item.deliverySummary?.links ?? []);
}
```

`deliveryText` and `serviceText` can contain rich-text HTML. Sanitize this HTML before you use `dangerouslySetInnerHTML` or a similar API.

Open an external delivery link only when `href` is not null. Use `rel="noopener noreferrer"` when the link opens a new browser tab.

Each serial has these fields:

| Field            | Use                                               |
| ---------------- | ------------------------------------------------- |
| `value`          | Contains the serial value.                        |
| `is_replacement` | Shows that the serial replaced an earlier serial. |
| `is_replaced`    | Shows that a newer serial replaced this serial.   |

<Note>
  The Shoppex editor does not edit a self-hosted customer panel. Your site must render these fields in its own order-detail component.
</Note>

### Delivery status behavior

| Status                 | Recommended UI                                                          |
| ---------------------- | ----------------------------------------------------------------------- |
| `PENDING`              | Do not show delivery content or promise a delivery message.             |
| `AWAITING_FULFILLMENT` | Show that the seller is preparing the item.                             |
| `DELIVERED`            | Show instructions, codes, serials, notes, and links.                    |
| `FAILED`               | Show that delivery is delayed and tell the buyer to contact the seller. |

This delivery status belongs to one line item. It is separate from the payment status of the complete order.

## Downloads

```ts theme={"system"}
const order = await customer.order('invoice-uuid');
const downloadIndex = order.lineItems
  .flatMap((item) => item.deliverySummary?.links ?? [])
  .find((item) => item.downloadIndex !== undefined)?.downloadIndex;

if (downloadIndex === undefined || downloadIndex === null) {
  throw new Error('This order has no downloadable file');
}

const file = await customer.download(order.uniqid, downloadIndex);
const url = URL.createObjectURL(file.blob);

const link = document.createElement('a');
link.href = url;
link.download = file.filename ?? 'download';
link.click();
URL.revokeObjectURL(url);
```

Shoppex checks that the invoice belongs to the signed-in buyer before it returns the file. Always pass the `downloadIndex` returned by `order()` or `dashboard()`. The SDK removes hosted-portal-only download URLs so a self-hosted site never follows a broken `/api/customer/*` link or receives an unsafe aggregate HTML launcher.

## Logout and session state

```ts theme={"system"}
const activeSession = customer.getSession();

await customer.logout();
customer.clearSession();
```

`logout()` revokes only this external customer session. It does not sign the buyer out of a separate Shoppex-hosted customer portal tab. If the network request fails, the SDK keeps the local token so your UI can retry the revocation; a successful logout or an already-unauthorized response clears it.

Use `clearSession()` only when you must remove local state without a server request. For normal logout, call `logout()` by itself.

## Error handling

Use `HeadlessCustomerError` to handle expected API errors. A `401` response clears an expired or rejected session.

```ts theme={"system"}
import { HeadlessCustomerError } from '@shoppexio/storefront/customer';

try {
  const order = await customer.order('invoice-uuid');
  console.log(order);
} catch (error) {
  if (error instanceof HeadlessCustomerError && error.status === 401) {
    // Return the buyer to your OTP login form.
  } else if (error instanceof HeadlessCustomerError) {
    console.error(error.status, error.code, error.message);
  } else {
    console.error('The customer request failed', error);
  }
}
```

## Implementation checklist

* Build the OTP request and OTP verification forms.
* Add a buyer-controlled **Keep me signed in** option.
* Use `me()` for the signed-in buyer and shop branding, then `dashboard()` for the account home.
* Use `orders()` for the order list and pagination.
* Use `order()` for pricing, payment, and delivery details.
* Render delivery state for every line item.
* Use `download()` only with a returned `downloadIndex`.
* Add account settings, support, license, subscription, and benefits screens from the focused methods above.
* Confirm destructive buyer actions in your UI immediately before calling the SDK.
* Handle `401` by returning the buyer to the login form.
* Add a logout action that calls `logout()`.

## Security boundary

Every customer request must pass all of these checks:

* valid `pk_live_` or `pk_test_` publishable key
* exact browser `Origin` in the shop's allowed-origin list
* active Shoppex shop
* valid `hcs_` customer session bound to the same origin and shop
* invoice ownership for order detail and downloads

The SDK exposes buyer-safe reads and mutations. A merchant controls the JavaScript on an allowed origin, so Shoppex does not expose actions that could silently move buyer money, reveal a one-time provider URL, or create a durable credential.

Keep these actions behind a Shoppex-controlled page or explicit hosted customer-portal handoff:

| Protected action                         | Reason                                                             |
| ---------------------------------------- | ------------------------------------------------------------------ |
| Affiliate payout or balance conversion   | Moves money.                                                       |
| Subscription payment-method portal       | Creates a signed provider session.                                 |
| Reseller order placement                 | Can spend the buyer's reseller balance.                            |
| Reseller API-key creation and revocation | Creates or destroys a durable credential.                          |
| Password and signed-in device management | The `hcs_` session is separate from the hosted BetterAuth session. |

For example, send the buyer to `https://your-shop.myshoppex.io/dashboard?tab=subscriptions` for a protected subscription payment-method change. The hosted portal may ask the buyer to sign in again because the headless session is intentionally not transferred to another origin.

Wallet top-ups and license renewals are available because they return a plain Shoppex checkout URL. Your UI redirects there and the buyer still chooses whether and how to pay.

An unsupported headless route returns `404`. Do not work around this by calling `/v1/customer/*` with a secret Developer API key or by proxying the buyer's `hcs_` token through your own backend.
