# Quote requests

The quote (RFQ) flow lets a buyer submit a cart to the merchant for a priced offer instead of paying directly. It is only available when the store has quotes enabled in its settings; on a store without quotes the endpoint returns 404. Certain carts must go through this flow: when a quote trigger fires, checkout refuses with `409 QUOTE_REQUIRED` and the cart's `requires_quote` field is true.

## Quote status flow

| Status | Meaning |
| --- | --- |
| `submitted` | Created by this endpoint. The cart is converted; the merchant sees the request. |
| `offered` | The merchant priced the lines and sent the offer. Totals are frozen and a 48-character accept token is emailed to the buyer (only its SHA-256 hash is stored server-side). |
| `accepted` | The buyer accepted via token. An order was created at the quoted prices; `converted_order_id` is set. Terminal. |
| `declined` | The buyer declined via token. The merchant can still re-offer or cancel. |
| `expired` | An offered quote past its `valid_until` flips to expired lazily on the next token or merchant touch. |
| `cancelled` | Cancelled by the merchant (possible from `submitted`, `offered`, `declined`, `expired`). Terminal. |

Accepting and declining happen through the token endpoints (see the Quote tokens page).

## Quote triggers

When the store has quotes enabled, these conditions force a cart into the RFQ flow. They surface live on every cart response as `requires_quote` and `quote_reasons`:

| Reason | Fires when |
| --- | --- |
| `cart_threshold` | The cart total reaches the configured threshold amount, or the quantity sum reaches the threshold quantity. |
| `configurator_choice` | A chosen configurator option choice is flagged as requiring a quote. |
| `customer_group` | The customer's group is configured to always quote. |
| `product_purchasability` | A product in the cart has purchasability set to `quote`. |

Submitting a quote request is also allowed for carts that do not trip any trigger; the buyer can always ask for an offer.

## The customer-facing quote shape

The quote request endpoint and both token read endpoints return the same envelope: `{"data": {...quote...}}`. This is the customer-facing serialization: `merchant_note`, `cart_id`, and `customer_id` are never present, and the accept token (or its hash) is never exposed. Unlike cart and order responses, all quote amounts are bare integers in minor units, not money objects. Dates are ISO-8601 strings.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | always | Quote id. |
| `number` | string | always | Human-facing quote number, `"Q-"` plus a per-store integer, for example `"Q-17"`. |
| `status` | string | always | See the status table above. |
| `email` | string | always | Requester email. |
| `name` | string | always | Requester name. |
| `phone` | string or null | always | Requester phone. |
| `billing_address` | object or null | always | As submitted. |
| `shipping_address` | object or null | always | As submitted. |
| `currency` | string | always | `EUR`. |
| `customer_note` | string or null | always | The buyer's note from the request. |
| `message_to_customer` | string or null | always | The merchant's message, set when offering. |
| `valid_until` | string (ISO-8601) or null | always | Offer expiry, set when offering. |
| `subtotal_amount` | integer or null | always | Frozen at offer time; null while `submitted`. Minor units. |
| `tax_total_amount` | integer or null | always | Frozen at offer time; null while `submitted`. |
| `shipping_total_amount` | integer or null | always | Frozen at offer time; null while `submitted`. |
| `total_amount` | integer or null | always | Frozen at offer time; null while `submitted`. |
| `tax_breakdown` | array or null | always | Per-rate breakdown `{"rate": float, "base": int, "tax": int}`; null while `submitted`. |
| `converted_order_id` | string (UUID) or null | always | The order created on acceptance, else null. |
| `submitted_at` | string (ISO-8601) or null | always | When the request was submitted. |
| `offered_at` | string (ISO-8601) or null | always | When the merchant sent the offer. |
| `accepted_at` | string (ISO-8601) or null | always | When the buyer accepted. |
| `declined_at` | string (ISO-8601) or null | always | When the buyer declined. |
| `cancelled_at` | string (ISO-8601) or null | always | When the merchant cancelled. |
| `created_at` | string (ISO-8601) or null | always | Row creation time. |
| `updated_at` | string (ISO-8601) or null | always | Last update time. |
| `lines` | array | always | Quote lines, see below. |

### `lines[]`

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | always | Quote line id. |
| `product_id` | string (UUID) or null | always | The product; null for custom lines the merchant added. |
| `variant_id` | string (UUID) or null | always | The variant, when applicable. |
| `is_custom` | boolean | always | True for merchant-added custom lines. |
| `title` | string | always | Line title (product name snapshot, or free text for custom lines). |
| `sku` | string or null | always | SKU snapshot. |
| `quantity` | integer | always | Quantity. |
| `indicative_unit_price` | integer or null | always | Unit price (minor units) snapshotted from the requester's pricing context at submit time. |
| `quoted_unit_price` | integer or null | always | The merchant's quoted unit price; null until priced. |
| `tax_class_id` | string (UUID) or null | always | Tax class the merchant assigned, if any. |
| `attributes` | object or null | always | Configuration snapshot for configurable lines, else null. |
| `sort_order` | integer | always | Display order. |

## POST /v1/api/commerce/carts/{id}/quote-request

Submits the cart as a quote request. Requires `commerce:write`, an `active` non-empty cart, and passes the cart ownership check. On success the cart becomes `converted`, a quote is created in status `submitted` with each cart line snapshotted at an indicative unit price from the requester's pricing context, a received-confirmation email is queued to the requester, and the customer-facing quote is returned with status 201.

Contact resolution: `email` and `name` are required overall, but each can come from either the request body or, when a valid `X-Customer-Token` is sent, the customer's profile. A valid token also links the quote to the customer.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. Must be an `active`, non-empty cart. |

**Body**

All body fields are optional and nullable; `email` and `name` become required when the customer profile cannot supply them.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | string | conditional | Valid email. Falls back to the token'd customer's profile email. |
| `name` | string | conditional | Requester name. Falls back to the customer profile name. |
| `phone` | string | no | Contact phone. |
| `billing_address` | object | no | Free-form address object. |
| `shipping_address` | object | no | Free-form address object. |
| `customer_note` | string | no | Message to the merchant. |

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | If present it must resolve, else 401. Links the quote to the customer, supplies contact fallbacks, and selects the pricing context for indicative prices. |

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/quote-request \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "buyer@corp.example",
    "name": "Bob Buyer",
    "phone": "+31 6 12345678",
    "customer_note": "Need delivery before October.",
    "billing_address": {
      "name": "Corp BV",
      "street": "Main St 1",
      "postal_code": "1000 AA",
      "city": "Amsterdam",
      "country": "NL"
    }
  }'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/commerce/carts/${cartId}/quote-request`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: "buyer@corp.example",
      name: "Bob Buyer",
      customer_note: "Need delivery before October.",
    }),
  }
);
const { data: quote } = await res.json();
```

```json title="Response"
{
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "submitted",
    "email": "buyer@corp.example",
    "name": "Bob Buyer",
    "phone": "+31 6 12345678",
    "billing_address": {
      "name": "Corp BV",
      "street": "Main St 1",
      "postal_code": "1000 AA",
      "city": "Amsterdam",
      "country": "NL"
    },
    "shipping_address": null,
    "currency": "EUR",
    "customer_note": "Need delivery before October.",
    "message_to_customer": null,
    "valid_until": null,
    "subtotal_amount": null,
    "tax_total_amount": null,
    "shipping_total_amount": null,
    "total_amount": null,
    "tax_breakdown": null,
    "converted_order_id": null,
    "submitted_at": "2026-08-14T10:15:30+00:00",
    "offered_at": null,
    "accepted_at": null,
    "declined_at": null,
    "cancelled_at": null,
    "created_at": "2026-08-14T10:15:30+00:00",
    "updated_at": "2026-08-14T10:15:30+00:00",
    "lines": [
      {
        "id": "9b0c1d2e-3f4a-4b5c-d6e7-f8a9b0c1d2e3",
        "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "variant_id": null,
        "is_custom": false,
        "title": "Garden Chair",
        "sku": "CHAIR-GRN",
        "quantity": 25,
        "indicative_unit_price": 2250,
        "quoted_unit_price": null,
        "tax_class_id": null,
        "attributes": null,
        "sort_order": 0
      }
    ]
  }
}
```

After submission there is no storefront endpoint to poll the quote by id: the buyer receives the offer by email, and the token endpoints take over from there.

### Errors

| Status | Body | When |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Quotes are disabled for the store, or the cart is unknown, expired, or not `active`. |
| 401 | `{"message": "Invalid customer token."}` | `X-Customer-Token` present but unresolvable. |
| 422 | `{"message": "...", "errors": {"email": ["Email is required."], "name": ["Name is required."]}}` | Neither the body nor the customer profile supplies email or name. |
| 422 | `{"message": "...", "errors": {"cart": ["Cart is empty."]}}` | The cart has no lines. |
| 403 | ownership, scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |
