borkoldocs

Carts & Checkout

Checkout

Convert an active cart into an order, with cart policy enforcement, price drift and stock conflicts, quote triggers, and idempotent replay.

View as Markdown

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

NameTypeRequiredDescription
idstring (UUID)yesCart id.

Body

NameTypeRequiredDescription
emailstringyesValid email address for the order.
billing_addressobjectyesBilling address.
billing_address.namestringyesFull name.
billing_address.streetstringyesStreet and number.
billing_address.postal_codestringyesPostal code.
billing_address.citystringyesCity.
billing_address.countrystringyesExactly 2 characters (ISO 3166-1 alpha-2).
shipping_addressobject or nullnoFree-form address object; the same shape as billing_address is recommended. Null or omitted means ship to the billing address.
shipping_method_idstring (UUID)conditionalRequired 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

NameTypeRequiredDescription
X-Customer-TokenstringnoIf 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.
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"
  }'
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();
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:

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

StatusBodyWhen
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.
403scope, module, or suspension bodySee the Carts page.
429{"message": "Too Many Attempts."}Rate limit exceeded.