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 MarkdownCheckout 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.
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:
- Ownership. Enforced inline rather than by middleware so that idempotent replay of an already-converted cart is exempt: an
activecart bound to a non-guest customer requires the matchingX-Customer-Token, else403 {"message": "This cart belongs to a customer."}. Guest-bound carts (from email capture) stay open. - Validation. Addresses and, when applicable, the shipping method are validated (see the body table).
- Customer token. If
X-Customer-Tokenis present it must resolve, else401 {"message": "Invalid customer token."}. A valid token binds the resulting order to that customer. - 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 fieldsrequires_quoteandquote_reasonssurface this ahead of time. - Cart policy. The tenant's cart policy is asserted; violations are 422 with a machine-readable
code(see errors). - 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.
- 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.
- Discounts. Usage limits are re-checked under a lock; an exhausted discount aborts with a 409 cart conflict carrying
discount_unavailable. - Order creation. The order is created with a per-store sequential integer
number, the cart becomesconverted, 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. |
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:
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. |
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"
}'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();{
"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"
}
}{
"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"
}
]
}POST /v1/api/commerce/carts/{id}/checkout
Requires commerce:write. What happens, in order:
- Ownership. Enforced inline rather than by middleware so that idempotent replay of an already-converted cart is exempt: an
activecart bound to a non-guest customer requires the matchingX-Customer-Token, else403 {"message": "This cart belongs to a customer."}. Guest-bound carts (from email capture) stay open. - Validation. Addresses and, when applicable, the shipping method are validated (see the body table).
- Customer token. If
X-Customer-Tokenis present it must resolve, else401 {"message": "Invalid customer token."}. A valid token binds the resulting order to that customer. - 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 fieldsrequires_quoteandquote_reasonssurface this ahead of time. - Cart policy. The tenant's cart policy is asserted; violations are 422 with a machine-readable
code(see errors). - 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.
- 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.
- Discounts. Usage limits are re-checked under a lock; an exhausted discount aborts with a 409 cart conflict carrying
discount_unavailable. - Order creation. The order is created with a per-store sequential integer
number, the cart becomesconverted, 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. |
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"
}'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();{
"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:
{
"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. |