# Cart review (`checkout`)

Cart review: the person reviews and edits the items, picks shipping if offered and enters delivery details. No payment is taken on the page. The result is the (possibly edited) cart and details; any purchase is up to you.

**When to use it:** Cart review: the person checks the items, picks shipping if offered and enters delivery details, and you get the cart back. No payment is taken on the page; any purchase is yours to make afterwards.

**When not to use it:** Do not use it to take a payment: it is a cart review. The person checks items and gives delivery details; you place the order yourself.

**Gather first:**

- The exact cart: items, quantities and prices, and the totals you will charge.
- Which address fields you need, and any address you already know to prefill.

**Writing good content:**

- Make totals add up: `total` must equal subtotal minus discount plus shipping plus tax for the default shipping option.
- Set `editable: true` only on lines the person may change, with `maxQuantity` when stock or policy limits it.
- Ask only for the address fields you need, and prefill `defaultValue` when you know it.
- Never word the button or text as if paying or placing the order: the page collects a reviewed cart and details, and you order afterwards.

## Fields

Every page also has `version` (always `1`), `title`, and optionally `description`, `branding`, `cover` (a header photo) and `expiresAt`; see [Page fields](/docs/api#page-fields). These are the fields of `page` (the page type and its data):

| Field | Description |
| --- | --- |
| `lineItems` | *object[], required.* What is in the cart, in display order (1-30 lines). Limits: 1-30 items. |
| `lineItems[].productId` | *string, required.* Your product id. Returned in the result. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `lineItems[].variantId` | *string.* Optional variant (SKU) id when the product has options. Returned in the result. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `lineItems[].name` | *string, required.* Product name, e.g. "Linen camp shirt". Limits: 1-120 characters. |
| `lineItems[].variantLabel` | *string.* Optional chosen options in plain words, e.g. "Sage · M". Limits: 1-80 characters. |
| `lineItems[].photo` | *object.* Optional product photo, shown as a small thumbnail. |
| `lineItems[].photo.url` | *https URL, required.* https URL of a product photo (JPEG, PNG or WebP), e.g. "https://shop.example/linen-shirt-front.jpg". |
| `lineItems[].photo.alt` | *string.* Optional. What the photo shows, for screen readers, e.g. "Sage linen shirt, front view". Omit to use the product name. Limits: 1-200 characters. |
| `lineItems[].unitPrice` | *object, required.* Price for one item, after any per-item discount. |
| `lineItems[].unitPrice.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `lineItems[].unitPrice.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `lineItems[].quantity` | *integer, required.* How many. Limits: 1-99. |
| `lineItems[].editable` | *boolean.* Optional. true lets the person change the quantity or remove this line. |
| `lineItems[].maxQuantity` | *integer.* Optional, with editable only: the most the person may order. Default 10. Limits: 1-99. |
| `merchant` | *object, required.* Who sells the items. |
| `merchant.name` | *string, required.* The seller, as the person knows it, e.g. "Juniper Market". Limits: 1-80 characters. |
| `merchant.supportEmail` | *email.* Optional email for questions. Limits: at most 200 characters. |
| `merchant.supportUrl` | *https URL.* Optional https URL of a help or returns page. |
| `totals` | *object, required.* Cart totals as sent. All amounts in one currency. |
| `totals.subtotal` | *object.* Optional. If given it must equal the sum of unitPrice × quantity. |
| `totals.subtotal.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `totals.subtotal.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `totals.discount` | *object.* Optional discount as a positive amount; it is subtracted. |
| `totals.discount.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `totals.discount.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `totals.shipping` | *object.* Optional shipping cost. With shippingOptions it must equal the default option price. |
| `totals.shipping.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `totals.shipping.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `totals.tax` | *object.* Optional tax total. |
| `totals.tax.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `totals.tax.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `totals.total` | *object, required.* The cart total: subtotal - discount + shipping + tax, for the default shipping option. |
| `totals.total.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `totals.total.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `shippingOptions` | *object[].* Optional delivery choices (1-6); the person picks one. Limits: 1-6 items. |
| `shippingOptions[].id` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `shippingOptions[].label` | *string, required.* e.g. "Standard" or "Express". Limits: 1-60 characters. |
| `shippingOptions[].price` | *object, required.* Shipping cost; 0 shows "Free". |
| `shippingOptions[].price.amount` | *number, required.* Amount in major units, e.g. 249.99 for $249.99. Limits: 0-10000000. |
| `shippingOptions[].price.currency` | *string, required.* ISO 4217 currency code, uppercase, e.g. "USD", "EUR". |
| `shippingOptions[].eta` | *string.* Optional delivery estimate, e.g. "Arrives Tue, Nov 10" or "3–5 business days". Limits: 1-60 characters. |
| `defaultShippingOptionId` | *string.* Optional id of the preselected shipping option. Default: the first. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `address` | *object.* Optional: ask for a shipping address. The browser can autofill it. Omit when you already have the address or nothing ships. |
| `address.label` | *string.* Optional heading, e.g. "Shipping address" (the default) or "Delivery details". Limits: 1-40 characters. |
| `address.fields` | *("name" \| "line1" \| "line2" \| "city" \| "region" \| "postalCode" \| "country" \| "phone" \| "email")[].* Optional fields to show, in this order. Default: name, line1, line2, city, region, postalCode, country. Limits: 1-9 items. |
| `address.required` | *("name" \| "line1" \| "line2" \| "city" \| "region" \| "postalCode" \| "country" \| "phone" \| "email")[].* Optional fields that must be filled. Default: every shown field except line2. Limits: at most 9 items. |
| `address.defaultValue` | *object.* Optional address you already know, prefilled for the person to confirm or edit. |
| `address.defaultValue.name` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.line1` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.line2` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.city` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.region` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.postalCode` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.country` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.phone` | *string.* Limits: 1-200 characters. |
| `address.defaultValue.email` | *string.* Limits: 1-200 characters. |
| `promoNote` | *string.* Optional display-only note, e.g. "SPRING10 applied: 10% off". Codes cannot be entered on the page. Limits: 1-120 characters. |
| `submitLabel` | *string.* Optional button text, e.g. "Confirm details". Default "Send cart". Do not imply a purchase ("Pay", "Place order"). Limits: 1-24 characters, matches `^[^<>]*$`. |

**Why these are required:** Safety: what is in the cart, from whom, and the total, which must add up from the lines.

**When optional fields are left out:** Lines are fixed; no shipping section, address form or promo note; totals show the subtotal and total; the button reads "Send cart". The result is `{ items, shippingOptionId?, address? }`: the person may change `editable` lines and pick shipping, and after a change the page shows an estimated total (subtotal + shipping) and leaves tax and discounts to the merchant. The page takes no payment and collects no card details; a `submitLabel` that implies a purchase ("Pay", "Buy", "Purchase", "Place order") is rejected.

## Minimal example

Only the required fields:

```json
{
  "version": 1,
  "title": "Review your cart",
  "page": {
    "type": "checkout",
    "lineItems": [
      {
        "productId": "mug",
        "name": "Enamel camp mug",
        "unitPrice": {
          "amount": 18,
          "currency": "USD"
        },
        "quantity": 2
      },
      {
        "productId": "beans",
        "name": "House coffee beans, 340 g",
        "unitPrice": {
          "amount": 16.5,
          "currency": "USD"
        },
        "quantity": 1
      }
    ],
    "merchant": {
      "name": "Juniper Market"
    },
    "totals": {
      "total": {
        "amount": 52.5,
        "currency": "USD"
      }
    }
  }
}
```

## Full example

```json
{
  "version": 1,
  "title": "Review your cart",
  "description": "Change anything you like, then send it back to me.",
  "branding": {
    "name": "Juniper Market",
    "accentColor": "#2f5d50"
  },
  "page": {
    "type": "checkout",
    "merchant": {
      "name": "Juniper Market",
      "supportEmail": "help@juniper-market.example"
    },
    "lineItems": [
      {
        "productId": "linen-camp-shirt",
        "variantId": "shirt-sage-m",
        "name": "Linen camp shirt",
        "variantLabel": "Sage · M",
        "photo": {
          "url": "https://images.example.test/shop/linen-shirt-sage-1.jpg",
          "alt": "Sage linen camp shirt"
        },
        "unitPrice": {
          "amount": 68,
          "currency": "USD"
        },
        "quantity": 1,
        "editable": true,
        "maxQuantity": 5
      },
      {
        "productId": "wool-throw",
        "name": "Waffle wool throw",
        "variantLabel": "Oatmeal",
        "photo": {
          "url": "https://images.example.test/shop/wool-throw-1.jpg",
          "alt": "Oatmeal waffle-knit wool throw"
        },
        "unitPrice": {
          "amount": 96,
          "currency": "USD"
        },
        "quantity": 1,
        "editable": true,
        "maxQuantity": 3
      },
      {
        "productId": "stoneware-mug",
        "name": "Speckled stoneware mug",
        "photo": {
          "url": "https://images.example.test/shop/stoneware-mug-1.jpg",
          "alt": "Speckled cream stoneware mug"
        },
        "unitPrice": {
          "amount": 24,
          "currency": "USD"
        },
        "quantity": 2
      }
    ],
    "promoNote": "WELCOME10 applied: 10% off",
    "shippingOptions": [
      {
        "id": "standard",
        "label": "Standard",
        "price": {
          "amount": 0,
          "currency": "USD"
        },
        "eta": "3–5 business days"
      },
      {
        "id": "express",
        "label": "Express",
        "price": {
          "amount": 14,
          "currency": "USD"
        },
        "eta": "Arrives Thu, Nov 5"
      }
    ],
    "address": {},
    "totals": {
      "subtotal": {
        "amount": 212,
        "currency": "USD"
      },
      "discount": {
        "amount": 21.2,
        "currency": "USD"
      },
      "shipping": {
        "amount": 0,
        "currency": "USD"
      },
      "tax": {
        "amount": 15.7,
        "currency": "USD"
      },
      "total": {
        "amount": 206.5,
        "currency": "USD"
      }
    }
  }
}
```

## Result

When the person submits, the result has these fields:

| Field | Description |
| --- | --- |
| `items` | *object[], required.* The cart lines the person kept, in spec order. Removed lines are left out. Re-price the cart yourself before any purchase. Limits: 1-30 items. |
| `items[].productId` | *string, required.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `items[].variantId` | *string.* Stable identifier you choose. It is returned in the result, so keep it meaningful to you. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `items[].quantity` | *integer, required.* The quantity the person kept. Limits: 1-99. |
| `shippingOptionId` | *string.* id of the chosen shipping option. Present when the spec has shippingOptions. Limits: matches `^[A-Za-z0-9_-]{1,64}$`. |
| `address` | *object.* The address the person entered or confirmed. Present when the spec has address. |
| `address.name` | *string.* Limits: 1-200 characters. |
| `address.line1` | *string.* Limits: 1-200 characters. |
| `address.line2` | *string.* Limits: 1-200 characters. |
| `address.city` | *string.* Limits: 1-200 characters. |
| `address.region` | *string.* Limits: 1-200 characters. |
| `address.postalCode` | *string.* Limits: 1-200 characters. |
| `address.country` | *string.* Limits: 1-200 characters. |
| `address.phone` | *string.* Limits: 1-200 characters. |
| `address.email` | *string.* Limits: 1-200 characters. |

For the full example above, a result could be:

```json
{
  "items": [
    {
      "productId": "linen-camp-shirt",
      "variantId": "shirt-sage-m",
      "quantity": 1
    },
    {
      "productId": "stoneware-mug",
      "quantity": 2
    }
  ],
  "shippingOptionId": "standard",
  "address": {
    "name": "Sam Lee",
    "line1": "410 Valencia St",
    "city": "San Francisco",
    "region": "CA",
    "postalCode": "94103",
    "country": "United States"
  }
}
```

## Send it

### eve, AI SDK and MCP

With [`@pagesystems/eve`](/docs#quickstart-eve), [`@pagesystems/ai-sdk`](/docs#quickstart-ai-sdk) or the [MCP server](/docs#quickstart-mcp), the model calls `get_page_type_schema` with `"checkout"` (the same JSON as [`GET /v1/page-types/checkout`](/docs/api#endpoints)), then `create_page` with this input (no `version` or `branding`; branding comes from your dashboard):

```json
{
  "title": "Review your cart",
  "description": "Change anything you like, then send it back to me.",
  "page": {
    "type": "checkout",
    "merchant": {
      "name": "Juniper Market",
      "supportEmail": "help@juniper-market.example"
    },
    "lineItems": [
      {
        "productId": "linen-camp-shirt",
        "variantId": "shirt-sage-m",
        "name": "Linen camp shirt",
        "variantLabel": "Sage · M",
        "photo": {
          "url": "https://images.example.test/shop/linen-shirt-sage-1.jpg",
          "alt": "Sage linen camp shirt"
        },
        "unitPrice": {
          "amount": 68,
          "currency": "USD"
        },
        "quantity": 1,
        "editable": true,
        "maxQuantity": 5
      },
      {
        "productId": "wool-throw",
        "name": "Waffle wool throw",
        "variantLabel": "Oatmeal",
        "photo": {
          "url": "https://images.example.test/shop/wool-throw-1.jpg",
          "alt": "Oatmeal waffle-knit wool throw"
        },
        "unitPrice": {
          "amount": 96,
          "currency": "USD"
        },
        "quantity": 1,
        "editable": true,
        "maxQuantity": 3
      },
      {
        "productId": "stoneware-mug",
        "name": "Speckled stoneware mug",
        "photo": {
          "url": "https://images.example.test/shop/stoneware-mug-1.jpg",
          "alt": "Speckled cream stoneware mug"
        },
        "unitPrice": {
          "amount": 24,
          "currency": "USD"
        },
        "quantity": 2
      }
    ],
    "promoNote": "WELCOME10 applied: 10% off",
    "shippingOptions": [
      {
        "id": "standard",
        "label": "Standard",
        "price": {
          "amount": 0,
          "currency": "USD"
        },
        "eta": "3–5 business days"
      },
      {
        "id": "express",
        "label": "Express",
        "price": {
          "amount": 14,
          "currency": "USD"
        },
        "eta": "Arrives Thu, Nov 5"
      }
    ],
    "address": {},
    "totals": {
      "subtotal": {
        "amount": 212,
        "currency": "USD"
      },
      "discount": {
        "amount": 21.2,
        "currency": "USD"
      },
      "shipping": {
        "amount": 0,
        "currency": "USD"
      },
      "tax": {
        "amount": 15.7,
        "currency": "USD"
      },
      "total": {
        "amount": 206.5,
        "currency": "USD"
      }
    }
  }
}
```

### REST

```bash
curl https://pagesystems.ai/v1/pages \
  -H "Authorization: Bearer $PAGE_SYSTEMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"spec":{"version":1,"title":"Review your cart","description":"Change anything you like, then send it back to me.","branding":{"name":"Juniper Market","accentColor":"#2f5d50"},"page":{"type":"checkout","merchant":{"name":"Juniper Market","supportEmail":"help@juniper-market.example"},"lineItems":[{"productId":"linen-camp-shirt","variantId":"shirt-sage-m","name":"Linen camp shirt","variantLabel":"Sage · M","photo":{"url":"https://images.example.test/shop/linen-shirt-sage-1.jpg","alt":"Sage linen camp shirt"},"unitPrice":{"amount":68,"currency":"USD"},"quantity":1,"editable":true,"maxQuantity":5},{"productId":"wool-throw","name":"Waffle wool throw","variantLabel":"Oatmeal","photo":{"url":"https://images.example.test/shop/wool-throw-1.jpg","alt":"Oatmeal waffle-knit wool throw"},"unitPrice":{"amount":96,"currency":"USD"},"quantity":1,"editable":true,"maxQuantity":3},{"productId":"stoneware-mug","name":"Speckled stoneware mug","photo":{"url":"https://images.example.test/shop/stoneware-mug-1.jpg","alt":"Speckled cream stoneware mug"},"unitPrice":{"amount":24,"currency":"USD"},"quantity":2}],"promoNote":"WELCOME10 applied: 10% off","shippingOptions":[{"id":"standard","label":"Standard","price":{"amount":0,"currency":"USD"},"eta":"3–5 business days"},{"id":"express","label":"Express","price":{"amount":14,"currency":"USD"},"eta":"Arrives Thu, Nov 5"}],"address":{},"totals":{"subtotal":{"amount":212,"currency":"USD"},"discount":{"amount":21.2,"currency":"USD"},"shipping":{"amount":0,"currency":"USD"},"tax":{"amount":15.7,"currency":"USD"},"total":{"amount":206.5,"currency":"USD"}}}}}'
```

## Validation errors

A spec that does not validate is refused with `400 invalid_spec`. The message names each problem and its path, so a model can fix the spec and try again. For example:

**Leaving out the required field `lineItems`:**

```text
✖ Invalid input: expected array, received undefined
  → at page.lineItems
```

**Misspelling a field name (unknown keys are rejected):**

```text
✖ Unrecognized key: "colour"
  → at page
```
