# 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 the sales module enabled and quote requests switched on in its settings; on a store without either 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 the sales module enabled and quote requests switched on, these conditions force a cart into the RFQ flow. Otherwise no trigger fires, `requires_quote` stays false, and checkout never refuses with `QUOTE_REQUIRED`. 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 contact group is set to always quote (a sales setting on the group). |
| `product_purchasability` | A product in the cart has `purchasability` `quote`. Such a product can still be added to a cart; the cart then reports this reason and goes through this endpoint instead of checkout. |

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`, `customer_id`, and `contact_id` are never present, and the accept token (or its hash) is never exposed. The quote object also carries `origin` (`"storefront"` for a quote created here), `intro`, `terms`, `signed_at`, `acceptance_mode`, `pdf_url` and `signed_pdf_url`, documented on the [Quote tokens](/api/orders-and-quotes/quote-tokens) page. 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` | number | always | Quantity, copied from the cart line: a whole number for a product sold by the `piece`, up to three decimals for any other unit (for example `0.75`). Merchant service and material lines may be fractional too. |
| `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. |
| `kind` | string | always | `product` for lines created from a cart; merchant lines may be `service`, `material`, or `other`. |
| `unit` | string or null | always | Unit label. A line created from a cart carries `pcs` for a product sold by the `piece` and the product's unit otherwise (`kg`, `m2`, `hour`, ...). |
| `description` | string or null | always | Optional line-level note. |
| `tax_rate` | string or null | always | Decimal tax rate snapshotted when the merchant sends the offer (for example `"21.00"`); null until then. |
| `unit_price` | integer or null | always | Alias of `quoted_unit_price`. |

## 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,
        "kind": "product",
        "unit": "pcs",
        "description": null,
        "tax_rate": null,
        "unit_price": null
      }
    ],
    "origin": "storefront",
    "intro": null,
    "terms": null,
    "signed_at": null,
    "acceptance_mode": "click",
    "pdf_url": null,
    "signed_pdf_url": null
  }
}
```

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 | `{"error": "Module not enabled: sales"}` | The store does not have the sales module enabled. Note the `error` key and the 404 status (other module gates answer 403). |
| 404 | `{"message": "..."}` | Quote requests are switched off 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, commerce module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |
