# Orders — customer reads + transfers

The authenticated customer's order surface: list, detail (items +
fulfillments with tracking + addresses) and order transfers. There is **no
anonymous order read** — every endpoint on this page requires a customer
session (`authorization: Bearer <jwt>`, obtained via the passwordless code
flow — see [auth.md](auth.md)); a guest's only order handle is the
`completeCart()` response ([checkout.md](checkout.md)). Missing/invalid
JWT → `401 {code: "unauthenticated"}` — demonstrated executably below;
happy-path shapes are shown as jsonc (a customer session is admin-owned
state on the shared tenant; the authenticated paths are proven by
`tests/store/customer-accounts-ownership.test.ts` and the transfer suite).

**Auth for every endpoint on this page:** `x-client-id` + Bearer JWT.

SDK module: `@cartbase/storefront/api/orders`.

---

## GET /api/store/orders/display/:displayId — my order by its human number

- **Purpose** — resolve "order #42" (the number customers see in emails and
  confirmations) to the full order detail; account order pages and support
  links use this instead of the opaque id.
- **Auth** — Bearer JWT required, same ownership scope as the id read:
  another customer's number is `404 not_found`. `display_id` is a guessable
  autoincrement — that is exactly why there is no anonymous lookup.
- **Request** — `GET`, no query. A numeric segment matches the
  autoincrement `display_id` OR a purely-numeric `custom_display_id`
  (custom wins ties — it is the number the store actually showed); any
  other segment matches `custom_display_id` only.
- **Response** — identical `{order}` detail shape to
  `GET /api/store/orders/:id` above.
- **Errors** — 401 `unauthenticated`; 404 `not_found`.
- **SDK** — `orders.retrieveOrderByDisplayId(client, displayId)`.
- **Components** — order-confirmation deep links, account order detail.
- **Settings** — `custom_display_id` is set by merchants/imports (admin
  surface); absent it, only the autoincrement resolves.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/orders/display/42" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401
```

---

## GET /api/store/orders — list my orders

- **Purpose** — the account "My orders" list, newest first.
- **Auth** — Bearer JWT required. Scoped to the session customer AND the
  tenant — another customer's orders are invisible (not 403).
- **Request** — `GET ?limit=&offset=&status=` — `limit` 1–200 (default
  20), `offset` ≥ 0 (default 0), `status` exact-match filter (`pending`,
  `completed`, `canceled`, …).
- **Response** — plain order rows (NO embeds — fetch the detail for
  items/tracking):

```jsonc
{
  "orders": [{
    "id": "order_…",
    "display_id": 42,              // human-facing autoincrement
    "status": "pending",
    "email": "jane@example.com",
    "currency_code": "eur",
    "customer_id": "cus_…",
    "sales_channel_id": null,
    "region_id": "reg_…",
    "metadata": {},                // internal staff notes NEVER appear here
    "created_at": "2026-07-20T…", "updated_at": "2026-07-20T…"
  }],
  "count": 1, "offset": 0, "limit": 20
}
```

- **Errors** — 401 `unauthenticated`; 400 `validation_failed` (bad
  limit/offset).
- **SDK** — `orders.listOrders(client, {limit, offset, status})`.
- **Components** — account order-history page.
- **Settings** — none (ownership is structural).

```bash
# Executable auth contract: no Bearer → 401 unauthenticated.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/orders" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401
```

---

## GET /api/store/orders/:id — my order, in full

- **Purpose** — the order-detail page: items, fulfillment lifecycle +
  tracking, both addresses.
- **Auth** — Bearer JWT required. Ownership is part of the lookup: an
  unknown id and another customer's order are BOTH `404 not_found`
  (indistinguishable — no existence oracle).
- **Request** — `GET`, no query.
- **Response** — a store-safe subset of the admin order select (no
  customer row, no payment internals, no internal timeline comments):

```jsonc
{
  "order": {
    "id": "order_…", "display_id": 42, "status": "pending",
    "email": "jane@example.com", "currency_code": "eur",
    "items": [{                       // version pivot + embedded line item
      "id": "oi_…", "order_id": "order_…", "quantity": 1,
      "line_item": {
        "id": "oli_…", "title": "M", "product_title": "Linen Shirt",
        "product_handle": "linen-shirt", "thumbnail": "https://…",
        "variant_id": "variant_…", "variant_sku": "LIN-SHIRT-M",
        "unit_price": 45, "metadata": null
      }
    }],
    "fulfillments": [{                // couriers surface
      "fulfillment": {
        "id": "ful_…",
        "packed_at": "2026-07-20T…", "shipped_at": null,
        "delivered_at": null, "canceled_at": null,
        "labels": [{ "tracking_number": "…", "tracking_url": "https://…" }]
      }
    }],
    "shipping_address": { "first_name": "Jane", "city": "Sofia", "…": "order_addresses row" },
    "billing_address":  { "…": "order_addresses row" }
  }
}
```

- **Errors** — 401 `unauthenticated`; 404 `not_found`.
- **SDK** — `orders.retrieveOrder(client, orderId)`.
- **Components** — order-detail page, tracking widget.
- **Settings** — fulfillment/tracking data appears as the merchant packs
  and ships (admin fulfillment flow + carrier labels).

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/orders/order_any" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401
```

---

## Order transfers — claim a (guest) order into my account

Flow: the **claiming** customer calls `request` on an order that is not
theirs → a one-time token is stored for the order's email holder (email
dispatch pending server-side) → the claimer, whose account email must
EQUAL the order's email, calls `accept` with the token. `decline` (the
recipient rejects, token required) or `cancel` (the requester withdraws)
end a pending transfer. One pending transfer per order — a duplicate
`request` acknowledges with the same `{transfer: {requested: true}}` shape
without creating another. **The token never crosses this surface** — it is
email-delivered only (pinned by
`tests/store/order-transfer-token-leak.test.ts`).

### POST /api/store/orders/:id/transfer/request

- **Request** — `{description?}`.
- **Response** — `200 {"order":{"id":"order_…","transfer":{"requested":true}}}`
  (or the existing pending action object on a duplicate request).
- **Errors** — 401 `unauthenticated`; 404 `not_found`; 400 `already_owned`
  (the order already belongs to the caller).
- **SDK** — `orders.requestOrderTransfer(client, orderId, {description})`.

### POST /api/store/orders/:id/transfer/accept

- **Request** — `{token}` (≥16 chars, from the transfer email).
- **Response** — `200 {order}` — the full updated order row, now owned by
  the caller.
- **Errors** — 401 `unauthenticated`; 404 `not_found` (order / no pending
  transfer); 403 `invalid_token` | `email_mismatch` (the caller's email
  must match the order's original email — a leaked token alone is not
  enough); 400 `validation_failed`.
- **SDK** — `orders.acceptOrderTransfer(client, orderId, {token})`.

### POST /api/store/orders/:id/transfer/decline

- **Request** — `{token}`.
- **Response** — `200 {"order":{"id":"order_…","transfer":{"declined":true}}}`.
- **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `invalid_token`;
  400 `validation_failed`.
- **SDK** — `orders.declineOrderTransfer(client, orderId, {token})`.

### POST /api/store/orders/:id/transfer/cancel

- **Request** — no body.
- **Response** — `200 {"order":{"id":"order_…","transfer":{"canceled":true}}}`.
- **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `forbidden`
  (only the requester may cancel).
- **SDK** — `orders.cancelOrderTransfer(client, orderId)`.
- **Components** — account "claim this order" flow + the transfer email's
  accept/decline landing page.

```bash
# Executable auth contract for the transfer family (all four Bearer-gated).
# Note the ordering nuance: accept/decline validate the BODY before auth,
# so a missing token 400s even for guests; request/cancel hit auth first.
for EP in transfer/request transfer/cancel; do
  STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
    "$BASE/api/store/orders/order_any/$EP" \
    -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
  test "$STATUS" = 401
done
for EP in transfer/accept transfer/decline; do
  RES=$(curl -s -X POST "$BASE/api/store/orders/order_any/$EP" \
    -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
  echo "$RES" | grep -q '"code":"validation_failed"'
  STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
    "$BASE/api/store/orders/order_any/$EP" \
    -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
    -d '{"token":"0123456789abcdef0123456789abcdef"}')
  test "$STATUS" = 401
done
```

## Cleanup / accretion note

This page creates nothing — every executable block is a read/auth-contract
probe against nonexistent ids.
