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

# Customer accounts

> Build a signed-in customer account into a code storefront, with the session rules that make it safe.

A code storefront can serve the customer account itself: sign-in, order history, downloads, support. The account is then part of your theme, with your layout, your type and your colors, instead of a separate page the buyer is sent to.

This page is about the Advanced lane. If your shop runs an Easy (visual) theme, the account is a block you place in the Builder and none of the code below applies.

<Note>
  The reference implementation ships with the Nova template, in `src/components/account/` and `src/commerce/account-route.ts`. Start from those files rather than from a blank page.
</Note>

## Where the account lives

There is exactly one account route.

| URL                                | Shows                                                                              |
| ---------------------------------- | ---------------------------------------------------------------------------------- |
| `/dashboard`                       | The account overview                                                               |
| `/dashboard?tab=orders`            | One account section, named by `tab`                                                |
| `/dashboard?tab=orders&order=<id>` | One order, inside the orders section                                               |
| `/customer-portal?...`             | The older spelling of the same route. Fold it into `/dashboard` and keep the query |

The ten section names are `overview`, `orders`, `downloads`, `subscriptions`, `rewards`, `referrals`, `favorites`, `support`, `settings` and `reseller`.

<Warning>
  Do not invent per-section paths such as `/dashboard/orders`. Order emails, password-free sign-in redirects and links customers already have are written against the query form, and it is identical on both Shoppex storefront lanes. A theme that routes its account differently breaks links it does not own.
</Warning>

Your storefront is a single-page app, so serve `/dashboard` from your own router and read the two parameters:

```tsx theme={"system"}
const params = new URLSearchParams(window.location.search);
const tab = params.get('tab') ?? 'overview';
const orderId = params.get('order');
```

A section your theme does not implement should say so. Answering it with the overview makes a broken link look like it worked, and the customer keeps hunting for something that is not there.

## How the session works

Sign-in is a single-use code sent by email. There is no password, and there is no redirect: the customer types the code into your page and the same page becomes their account.

<Steps>
  <Step title="The customer asks for a code">
    `requestOtp(email)` sends the address and nothing else.
  </Step>

  <Step title="Shoppex emails the code">
    The code is short lived. Its lifetime and retry limits are enforced on the server.
  </Step>

  <Step title="The customer types it back">
    `verifyOtp(email, otp)` completes the sign-in.
  </Step>

  <Step title="The edge sets the session cookie">
    The Shoppex storefront worker sets an HttpOnly cookie on your own domain, scoped to that host. Every later call carries it automatically.
  </Step>
</Steps>

Three consequences shape everything you write:

<CardGroup cols={3}>
  <Card title="No token in JavaScript" icon="key">
    Nothing hands your code a session token, and nothing should store one. The cookie is HttpOnly, so your scripts cannot read it even by accident.
  </Card>

  <Card title="No shop parameter" icon="store">
    The shop is derived from the host the buyer is on. Never send a shop, a shop id or a slug with an account call.
  </Card>

  <Card title="Same origin only" icon="shield">
    Calls go to your own domain under `/api/customer/*`. Anything that changes data must come from your own pages; the edge refuses cross-site writes before they reach the API.
  </Card>
</CardGroup>

<Warning>
  Use the SDK for every account call. A hand-rolled `fetch` client against `/api/customer/*` is how a storefront ends up sending a shop or a token it was never meant to choose, and it drops the response validation the SDK does for you.
</Warning>

## The account calls

Load the Storefront SDK as you already do for the catalog and the cart. In a code storefront that is the CDN global (`window.shoppex`), wrapped by `src/commerce/shoppex.ts` in the official templates. In a bundled project, the same functions are named exports of `@shoppexio/storefront`.

Every call answers with the same envelope: `{ success, data?, message?, code? }`. `message` is a sentence to show the customer. `code` is the machine-readable reason, when the server sent one, and is the only value you should branch on.

| Call                                                                                               | Method and endpoint                           | Returns                                                      |
| -------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------ |
| `requestOtp(email)`                                                                                | `POST /api/customer/auth/otp/request`         | Nothing to render. Move to the code step                     |
| `verifyOtp(email, otp)`                                                                            | `POST /api/customer/auth/otp/verify`          | Nothing secret. The session cookie arrives with the response |
| `logout()`                                                                                         | `POST /api/customer/auth/logout`              | Ends the session and clears the cookie                       |
| `me()`                                                                                             | `GET /api/customer/me`                        | The signed-in customer, or a failure when there is none      |
| `dashboard()`                                                                                      | `GET /api/customer/dashboard`                 | Shop, customer, counters, store credit, recent orders        |
| `orders({ page, limit })`                                                                          | `GET /api/customer/invoices`                  | One page of order history                                    |
| `order(id)`                                                                                        | `GET /api/customer/invoice/<id>`              | One order with its line items, delivery and payment          |
| `createTicket({ title, message, invoice_id })`                                                     | `POST /api/customer/tickets`                  | Opens a support ticket                                       |
| `ticket(uniqid)` / `replyToTicket(uniqid, message)`                                                | `GET` and `POST /api/customer/tickets/<id>…`  | One conversation, and an answer to it                        |
| `resetLicenseHwid(uniqid)`                                                                         | `POST /api/customer/licenses/<id>/reset-hwid` | Unbinds a licence from its device                            |
| `cancelSubscription(uniqid, options)` / `pauseSubscription(uniqid)` / `resumeSubscription(uniqid)` | `POST /api/customer/subscriptions/<id>/…`     | Changes a subscription's standing                            |
| `updateProfile({ name })`                                                                          | `POST /api/customer/profile`                  | Changes the display name                                     |
| `sessions()` / `revokeSession(id)` / `revokeAllSessions()`                                         | `GET` and `DELETE /api/customer/sessions`     | The customer's signed-in devices                             |

<Note>
  **Licences, subscriptions and tickets have no list call.** All three arrive
  inside `dashboard()`, and the calls above act on one item whose id you already
  have. `GET /api/customer/licenses` is not an endpoint and answers `404` at the
  edge — the same is true for `/subscriptions` and `/tickets`.
</Note>

`dashboard()`, `orders()` and `order()` are validated against the published response contract before you see them. A payload that does not match comes back as a failure rather than as half-read data, so a change on the platform side surfaces as an error instead of as a wrong number on a customer's screen.

## Sign a customer in

```javascript theme={"system"}
async function sendCode(email) {
  const result = await shoppex.requestOtp(email);
  if (!result.success) {
    // The server's own sentence. Show it as it came: it knows whether the
    // address is rate limited, blocked, or simply malformed.
    return showError(result.message);
  }
  showCodeStep(email);
}

async function signIn(email, code) {
  // The field is the code the customer typed. Nothing here returns a token,
  // and nothing needs to be stored: the cookie is set by the edge.
  const result = await shoppex.verifyOtp(email, code);
  if (!result.success) return showError(result.message);

  renderAccount();
}
```

Decide what a signed-in page looks like by asking, not by remembering:

```javascript theme={"system"}
const session = await shoppex.me();

if (session.success) renderAccount();
else if (session.status === 401) renderSignInForm();
else renderError(session.message);
```

<Note>
  **Only a `401` means "not signed in".** Every other failure — a `403`, a `5xx`,
  a contract mismatch, or a network error that carries no `status` at all —
  means the answer is unknown. Rendering the sign-in form there tells a buyer
  whose session is still live that they were signed out, and a reload
  contradicts you. Show the error and let them retry.

  Never do the opposite either and treat a failed check as a signed-in customer:
  that renders an account shell around data nobody fetched.
</Note>

## The overview

One call carries the whole overview. Read what you need out of it and count nothing yourself.

```javascript theme={"system"}
const { success, data, message } = await shoppex.dashboard();
if (!success) return showError(message);

console.log(data.customer.name, data.customer.email);
console.log(data.stats.invoices_count, data.stats.licenses_count);

// Store credit is a section, not a page. `wallet` is null for shops that run
// no wallet, and that null is the switch: render nothing rather than a zero
// balance for a feature the merchant never turned on.
if (data.wallet && data.wallet.enabled) {
  showStoreCredit(data.wallet.available, data.wallet.currency);
}
```

`data.invoices` holds the most recent orders, which is enough for a "recent orders" block without a second request.

## Order history

Pagination is page based. Ask for a page and a size, and read `has_more` to decide whether a next page exists.

```javascript theme={"system"}
const { success, data, message } = await shoppex.orders({ page: 1, limit: 10 });
if (!success) return showError(message);

renderRows(data.invoices);
setNextEnabled(data.pagination.has_more);
```

<Warning>
  There is no cursor. A cursor parameter is ignored by the API, which then keeps answering with page one, so a list built on one silently stops advancing.
</Warning>

An order row carries `uniqid`, and that is the id the detail call takes:

```javascript theme={"system"}
const { success, data, message } = await shoppex.order(uniqid);
if (!success) return showError(message);

for (const item of data.lineItems) {
  // Fulfillment is per line and independent of payment: a paid order can still
  // hold items the shop delivers later.
  console.log(item.productTitle, item.deliveryStatus);
  if (item.deliverySummary) {
    showDelivery(item.deliverySummary.codes, item.deliverySummary.serials);
  }
}
```

## Two details that bite

**Field casing is not uniform.** The dashboard payload is snake\_case (`total_display`, `created_at`, `line_items`), while the order list and the order detail are camelCase (`totalDisplay`, `createdAt`, `lineItems`). Keep the names as they arrive. Renaming them in a shared helper is how one of the two surfaces quietly starts reading `undefined`.

**`total` and `currency` are not a pair.** `currency` is the currency the buyer sees. The `total` field is a normalized figure in USD, and `total_display` / `totalDisplay` is the same total in the buyer's currency. Render the display amount first:

```javascript theme={"system"}
function orderAmount(order, currency) {
  return formatMoney(order.totalDisplay ?? order.total, currency);
}
```

The same rule applies to `discount` and `discountDisplay`. Line prices, `subtotal` and `tax` are already in the buyer's currency.

## What a storefront cannot do

The edge keeps a fixed list of account endpoints, bound to their method. Anything outside it answers `404` before it reaches the API, whatever the page calls it from. Your theme runs on the same origin as the customer's session, so that list is the answer to what shop-authored code may do on a buyer's behalf, and it is not something a theme can widen.

Deliberately closed today:

| Not available                          | Why                                                                                                   |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| License renewal                        | It creates a payable invoice. Money paths get their own review before any storefront can trigger them |
| Affiliate payouts                      | Same reason                                                                                           |
| The subscription payment-method portal | It hands out a signed redirect into a payment provider                                                |
| Reseller endpoints                     | No published contract yet                                                                             |
| Wallet top-up and transfers            | Balances are readable through `dashboard()`. Moving money is not open                                 |

Link a customer to the hosted flow for anything on that list rather than trying to reach it from your theme.

## Failures and empty states

Account pages fail in ways catalog pages do not: the session expires, the network drops mid-page, a shop turns a feature off. Three habits keep that honest.

* **Show the server's sentence.** `message` is written for the customer. A friendlier guess of your own is often simply wrong, and a customer who is told the wrong reason cannot fix anything.
* **Tell the three states apart.** Loading, empty and failed are different. An error rendered as an empty list reads as "you have no orders", which is the one thing it does not mean.
* **Never fill a gap.** If the API did not send a value, do not compute a replacement. A number your storefront made up is worse than a missing one, because nobody can tell.

## Where to go next

<CardGroup cols={2}>
  <Card title="Code storefront development" icon="code" href="/storefront/code-storefront-development">
    Project structure, the commerce layer, and working locally.
  </Card>

  <Card title="Storefront SDK reference" icon="cube" href="/developers/storefront-sdk/reference">
    Every SDK call, including the catalog, cart and checkout surfaces.
  </Card>
</CardGroup>
