# Storefront architecture

The Borkol storefront API is authenticated with a single tenant API key that can read every order and mutate every cart in the store. That one fact dictates the architecture: the key lives on a server you control, and the browser only ever talks to that server. This page describes the recommended shape, with Next.js as the worked example; the same layering applies to any server-rendered or backend-for-frontend stack.

## The key stays server-side

Never ship the API key to the browser, a mobile binary, or any client-delivered bundle. Concretely:

- With `commerce:write`, anyone holding the key can mutate any cart in the store and convert carts to orders.
- `GET /v1/api/commerce/orders/{id}` has no customer-ownership check; the key alone authorizes it. A leaked key exposes every order's email and address.
- The rate limit (120 requests per minute) is per store, not per visitor. A key in the browser lets one visitor exhaust the whole store's budget.

In Next.js terms: the key lives in an environment variable without the `NEXT_PUBLIC_` prefix, and is only read in route handlers, server components, and server actions. Grep your client bundles for the key prefix during CI if you want a hard guarantee.

The customer session token (`X-Customer-Token`) is also a bearer credential. Keep it in a server-side session or an `httpOnly` cookie your proxy reads; do not hand it to client-side JavaScript.

## The proxy pattern

Give the browser a small, storefront-shaped API served by your own app, and let that layer call Borkol:

```
Browser ── /api/* (your app) ── https://api.borkol.com/v1/api/*
                 |
                 adds Authorization: Bearer {key}
                 adds X-Customer-Token from the visitor's session
                 enforces YOUR ownership rules
                 maps Borkol errors to UI-safe shapes
```

A minimal Next.js route handler pair:

```javascript title="app/api/cart/route.ts"
import { cookies } from "next/headers";

const BASE = process.env.BORKOL_API_URL; // https://api.borkol.com
const KEY = process.env.BORKOL_API_KEY;

async function borkol(path, init = {}) {
  const jar = await cookies();
  const customerToken = jar.get("customer_token")?.value;
  return fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${KEY}`,
      "Content-Type": "application/json",
      ...(customerToken ? { "X-Customer-Token": customerToken } : {}),
      ...init.headers,
    },
    cache: "no-store",
  });
}

export async function POST() {
  const res = await borkol("/v1/api/commerce/carts", { method: "POST" });
  const body = await res.json();
  const jar = await cookies();
  jar.set("cart_id", body.data.id, { httpOnly: true, sameSite: "lax", path: "/" });
  return Response.json(body, { status: res.status });
}

export async function GET() {
  const jar = await cookies();
  const cartId = jar.get("cart_id")?.value;
  if (!cartId) return Response.json({ data: null }, { status: 200 });
  const res = await borkol(`/v1/api/commerce/carts/${cartId}`);
  return Response.json(await res.json(), { status: res.status });
}
```

Rules for the proxy layer:

- **The cart id is the visitor's handle.** Store it in an `httpOnly` cookie set by your proxy, never trusting a cart id sent by the client. Whoever knows a cart id can read and mutate that cart through your proxy, so treat it like a session id.
- **Enforce order ownership yourself.** Before proxying `GET /v1/api/commerce/orders/{id}`, check that the order id was produced by this visitor's own checkout (for example, store the order id in the session at checkout time). For logged-in customers, prefer `GET /v1/api/commerce/my/orders`, which Borkol scopes to the token for you.
- **Do not build a generic passthrough.** A route that forwards any path under `/v1/api` with your key attached recreates the leaked-key problem one hop away. Expose only the operations your UI needs, with the narrowest inputs.
- **Attach `X-Customer-Token` server-side** from your session on cart, checkout, quote-request, and customer endpoints. This gives logged-in visitors their group pricing everywhere without the client ever seeing the token.

## Caching: catalog yes, carts never

The API splits cleanly into cacheable reads and per-visitor state.

**Cache freely (shared, seconds to minutes):**

- `GET /v1/api/commerce/categories`: the tree changes rarely. Minutes of TTL is fine.
- `GET /v1/api/commerce/products` and `GET /v1/api/commerce/products/{id}` for guest traffic: cache per URL (including `filter[category]` and `page`). This is also your main lever against the 120 requests per minute limit; a busy catalog page served from cache costs zero budget.
- `GET /v1/api/commerce/configurator/products/{id}/schema`: cache per product for minutes; merchants edit configurators infrequently.

In Next.js, use `fetch` revalidation (`next: { revalidate: 60 }`) or ISR on catalog pages, and invalidate via `revalidateTag` from your webhook receiver when a `stock.adjusted` event arrives.

**Never cache, or cache only per-visitor:**

- Any request carrying `X-Customer-Token`. Product responses are priced per customer context (group prices, ex-VAT display, hidden guest prices), so a shared cache would leak one customer's pricing to another. Either skip caching for logged-in traffic or key the cache by customer group.
- Carts, checkout, shipping methods for a cart, quote endpoints, `my/orders`: always `cache: "no-store"`. Cart responses are the source of truth for totals and policy; a stale cart renders wrong prices.
- `POST /v1/api/commerce/configurator/products/{id}/quote`: it is cheap and selection-dependent; memoize at most per exact request body within a session.

**Stock display:** `stock_status` is embedded in cached product responses, so it goes stale with the cache. That is acceptable because checkout re-checks stock authoritatively and returns a structured 409 if something ran out; treat catalog stock badges as advisory.

## Error handling and idempotency

Map the Borkol error envelopes once, in the proxy, into shapes your UI switches on. The complete catalog is on [Errors and rate limits](/getting-started/errors-and-rate-limits); the ones your storefront must handle by code rather than by message:

| Status | Discriminator | Storefront reaction |
| --- | --- | --- |
| 401 | `message: "Unauthenticated."` | Your key is bad or revoked. Operational alert, not a user error. |
| 401 | `message: "Invalid customer token."` | Customer session expired. Clear the session, ask the visitor to log in again. |
| 403 | `code: "tenant_suspended"` | Store-wide outage state. Show a maintenance page. |
| 403 | `error: "Module not enabled: ..."` | Misconfiguration (key or store). Operational alert. |
| 403 | `message: "This cart belongs to a customer."` | Cart is bound to an account. Prompt login, then retry with the token. |
| 409 | `code: "QUOTE_REQUIRED"` | Route to the quote request flow. |
| 409 | `message: "Cart contents changed."` | Re-render the cart from the `cart` object in the error body; surface `out_of_stock` and `discount_unavailable`; let the buyer retry. |
| 409 | `code: "QUOTE_NOT_OFFERED" / "OFFER_CHANGED" / "OUT_OF_STOCK"` | Quote token page states: expired/withdrawn, changed (ask merchant to re-send), or shortage. |
| 422 | `code` present (`login_required`, `guest_only`, `b2b_approval_required`, `b2c_only`, `min_order_value`, `cart_constraint`) | Cart policy violations: each maps to one specific UI state. |
| 422 | `errors` object | Field validation. Render per-field messages; the API's messages are user-ready. |
| 429 | `message: "Too Many Attempts."` | Honor `Retry-After`. Back off; do not retry-storm. |

Idempotency and retries:

- **Checkout is safe to retry.** Re-POSTing checkout for an already-converted cart returns the existing order with 200. On a network timeout during checkout, retry the same request instead of showing an error; you cannot create a duplicate order.
- **Quote accept is idempotent** the same way: accepting an already-accepted quote returns the same `order_id`.
- **Cart line adds are NOT idempotent.** Retrying a successful `POST .../lines` merges quantities (2 becomes 4). Only auto-retry line mutations on network-level failures where no response arrived, and reconcile by re-fetching the cart and comparing to the expected state rather than blindly re-POSTing.
- **Reads are always safe to retry** with exponential backoff on 429 and 5xx.
- Budget the rate limit: 120 requests per minute for the whole store means cache catalog reads, batch nothing into per-render fan-outs, and never poll order status faster than webhooks would tell you anyway.

## Environment variables

Conventions that keep the key out of the client bundle and the environments separable:

```bash title=".env (storefront server)"
# Server-only: no NEXT_PUBLIC_ prefix, ever, on these three.
BORKOL_API_URL=https://api.borkol.com
BORKOL_API_KEY=1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
BORKOL_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# Client-safe values carry the prefix and contain no secrets.
NEXT_PUBLIC_STORE_NAME="Four Seasons Garden"
```

- One key per environment. Create separate keys named for their deployment (`storefront-prod`, `storefront-staging`) so each can be revoked independently and `last_used_at` in the dashboard tells you which is live.
- Scope keys minimally per surface. A build-time catalog prerenderer needs only `commerce:read`; give it its own read-only key rather than reusing the checkout server's write key.
- The webhook secret is per endpoint and shown once at creation, like the API key. Store both in your secret manager, not in the repository.
- Key rotation is create-new, deploy, revoke-old. Keys have no expiry, so rotation discipline is yours.
