# Checkout

Checkout converts an `active` cart into an order in status `pending_payment`. It is the strictest endpoint in the cart flow: it re-validates every line, re-resolves every price, re-checks stock and discount usage limits, and enforces the store's cart policy and quote triggers. A successful checkout marks the cart `converted` and queues an order-confirmation email.

## POST /v1/api/commerce/carts/{id}/checkout

Requires `commerce:write`. What happens, in order:

1. **Ownership.** Enforced inline rather than by middleware so that idempotent replay of an already-converted cart is exempt: an `active` cart bound to a non-guest customer requires the matching `X-Customer-Token`, else `403 {"message": "This cart belongs to a customer."}`. Guest-bound carts (from email capture) stay open.
2. **Validation.** Addresses and, when applicable, the shipping method are validated (see the body table).
3. **Customer token.** If `X-Customer-Token` is present it must resolve, else `401 {"message": "Invalid customer token."}`. A valid token binds the resulting order to that customer.
4. **Quote triggers.** If the store has quotes enabled and the cart trips a trigger, checkout refuses with `409 {"code": "QUOTE_REQUIRED", "reasons": [...]}`. The cart must go through the quote request flow instead. The cart response fields `requires_quote` and `quote_reasons` surface this ahead of time.
5. **Cart policy.** The tenant's cart policy is asserted; violations are 422 with a machine-readable `code` (see errors).
6. **Stock.** Stock is re-checked and a hold is reserved for at most 60 minutes at checkout start; the hold is converted into a real deduction on success.
7. **Re-pricing.** Every line is re-validated (product active, variant active, configuration still valid) and re-priced. Any drift or unavailability aborts with a 409 cart conflict; the refreshed prices are persisted to the cart so the storefront can re-render and retry.
8. **Discounts.** Usage limits are re-checked under a lock; an exhausted discount aborts with a 409 cart conflict carrying `discount_unavailable`.
9. **Order creation.** The order is created with a per-store sequential integer `number`, the cart becomes `converted`, checkout funnel events are recorded, and a confirmation email is queued.

**Shipping requirement.** `shipping_method_id` is required only when both conditions hold: the cart contains at least one shippable line (product type `simple`, `variable`, or `configurable`), and the store has at least one active shipping method. Otherwise the field is optional and ignored. Carts of only `digital`, `license`, or `subscription` products never require it.

**Idempotency.** Re-POSTing checkout for an already-converted cart returns the existing order with status 200 (a first success returns 201). A unique constraint on the cart id backstops concurrent double-submits. On replay, the body is still validated for the address fields, but `shipping_method_id` is not required and the ownership token check is skipped.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | yes | Valid email address for the order. |
| `billing_address` | object | yes | Billing address. |
| `billing_address.name` | string | yes | Full name. |
| `billing_address.street` | string | yes | Street and number. |
| `billing_address.postal_code` | string | yes | Postal code. |
| `billing_address.city` | string | yes | City. |
| `billing_address.country` | string | yes | Exactly 2 characters (ISO 3166-1 alpha-2). |
| `shipping_address` | object or null | no | Free-form address object; the same shape as `billing_address` is recommended. Null or omitted means ship to the billing address. |
| `shipping_method_id` | string (UUID) | conditional | Required when the cart has shippable lines and the store has at least one active shipping method. Must be an active shipping method id. Optional and ignored otherwise. |

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | If present it must resolve (else 401). Binds the order to the customer and is required when the cart is bound to a non-guest customer. |

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/checkout \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "billing_address": {
      "name": "Jane Doe",
      "street": "Keizersgracht 1",
      "postal_code": "1015 CC",
      "city": "Amsterdam",
      "country": "NL"
    },
    "shipping_address": null,
    "shipping_method_id": "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a"
  }'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/commerce/carts/${cartId}/checkout`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: "jane@example.com",
      billing_address: {
        name: "Jane Doe",
        street: "Keizersgracht 1",
        postal_code: "1015 CC",
        city: "Amsterdam",
        country: "NL",
      },
      shipping_address: null,
      shipping_method_id: "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a",
    }),
  }
);
if (res.status === 409) {
  const conflict = await res.json();
  // conflict.code === "QUOTE_REQUIRED" or conflict.message === "Cart contents changed."
}
const { data: order } = await res.json();
```

```json title="Response"
{
  "data": {
    "id": "7f8a9b0c-1d2e-4f3a-b4c5-d6e7f8a9b0c1",
    "number": 1042,
    "status": "pending_payment",
    "email": "jane@example.com",
    "billing_address": {
      "name": "Jane Doe",
      "street": "Keizersgracht 1",
      "postal_code": "1015 CC",
      "city": "Amsterdam",
      "country": "NL"
    },
    "shipping_address": null,
    "currency": "EUR",
    "lines": [
      {
        "id": "8a9b0c1d-2e3f-4a4b-c5d6-e7f8a9b0c1d2",
        "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "variant_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "variant_label": "Green / M",
        "name": "Garden Chair",
        "sku": "CHAIR-GRN-M",
        "quantity": 2,
        "unit_price": { "amount": 2250, "currency": "EUR" },
        "line_subtotal": { "amount": 3347, "currency": "EUR" },
        "tax_rate": 21,
        "tax": { "amount": 703, "currency": "EUR" },
        "line_total": { "amount": 4050, "currency": "EUR" },
        "attributes": null
      }
    ],
    "subtotal": { "amount": 3347, "currency": "EUR" },
    "tax_total": { "amount": 789, "currency": "EUR" },
    "total": { "amount": 4545, "currency": "EUR" },
    "tax_breakdown": [{ "rate": 21, "base": 3756, "tax": 789 }],
    "shipping_method_name": "Standard",
    "shipping": { "amount": 409, "currency": "EUR" },
    "shipping_tax": { "amount": 86, "currency": "EUR" },
    "tracking_carrier": null,
    "tracking_number": null,
    "invoice_number": null,
    "invoiced_at": null,
    "customer_id": null,
    "placed_at": "2026-08-14T10:15:30+00:00",
    "created_at": "2026-08-14T10:15:30+00:00"
  }
}
```

Status 201 on a new order, 200 on idempotent replay. The full order field reference is on the Orders page; the shape here is identical.

### The 409 cart conflict response

When re-pricing detects drift, a line has become unavailable, stock is short, or a discount's usage limit is exhausted, checkout aborts with status 409:

```json title="Response (409 cart conflict)"
{
  "message": "Cart contents changed.",
  "cart": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "lines": [
      {
        "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
        "unavailable": false,
        "...": "same line shape as GET /v1/api/commerce/carts/{id}"
      }
    ],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  },
  "out_of_stock": [
    {
      "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "variant_id": null,
      "requested": 2,
      "available": 1
    }
  ],
  "discount_unavailable": [
    {
      "discount_id": "71c9e0aa-2f4b-4d31-9a77-0b1c2d3e4f5a",
      "name": "Welcome 10%",
      "code": "WELCOME10"
    }
  ]
}
```

`cart` is the full cart resource with the refreshed (already persisted) prices, plus an extra boolean `unavailable` on every line. `out_of_stock` and `discount_unavailable` are only present when they apply. The correct client reaction is to re-render the cart from `cart`, let the shopper confirm, and POST checkout again.

### Errors

| Status | Body | When |
| --- | --- | --- |
| 422 | `{"message": "...", "errors": {...}}` | Address or email validation failure; also `Cart is empty.` and `Choose a valid shipping method.` |
| 422 | `{"message": "Log in to complete this order.", "code": "login_required"}` | Cart policy violation. Other codes: `guest_only`, `b2b_approval_required`, `b2c_only`, `min_order_value`. |
| 401 | `{"message": "Invalid customer token."}` | `X-Customer-Token` present but unresolvable. |
| 403 | `{"message": "This cart belongs to a customer."}` | Active customer-bound cart without the owner's token. |
| 409 | `{"code": "QUOTE_REQUIRED", "reasons": ["cart_threshold"]}` | The cart must go through the quote request flow. Reasons: `cart_threshold`, `configurator_choice`, `customer_group`, `product_purchasability`. |
| 409 | `{"message": "Cart contents changed.", "cart": {...}, ...}` | Price drift, unavailable line, out of stock, or exhausted discount (see above). |
| 404 | `{"message": "..."}` | Unknown cart id or a cart of another store. |
| 403 | scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |
