# Offerteaanvragen

Met de offerteflow (RFQ) kan een koper een winkelwagen bij de merchant indienen voor een geprijsd aanbod in plaats van direct te betalen. Deze is alleen beschikbaar wanneer de winkel de sales-module heeft ingeschakeld en offerteaanvragen aan heeft staan in de instellingen; op een winkel zonder een van beide retourneert het endpoint 404. Bepaalde winkelwagens moeten via deze flow: wanneer een quote trigger afgaat, weigert de checkout met `409 QUOTE_REQUIRED` en is het veld `requires_quote` van de winkelwagen true.

## Offertestatusflow

| Status | Betekenis |
| --- | --- |
| `submitted` | Aangemaakt door dit endpoint. De winkelwagen is geconverteerd; de merchant ziet de aanvraag. |
| `offered` | De merchant heeft de regels geprijsd en het aanbod verzonden. Totalen zijn bevroren en een accept token van 48 tekens is per e-mail naar de koper gestuurd (alleen de SHA-256-hash ervan wordt server-side opgeslagen). |
| `accepted` | De koper heeft geaccepteerd via het token. Er is een bestelling aangemaakt tegen de geoffreerde prijzen; `converted_order_id` is gezet. Terminaal. |
| `declined` | De koper heeft afgewezen via het token. De merchant kan nog opnieuw een aanbod doen of annuleren. |
| `expired` | Een aangeboden offerte waarvan `valid_until` is verstreken, klapt lazy om naar expired bij de eerstvolgende token- of merchant-actie. |
| `cancelled` | Geannuleerd door de merchant (mogelijk vanuit `submitted`, `offered`, `declined`, `expired`). Terminaal. |

Accepteren en afwijzen gebeurt via de token-endpoints (zie de pagina Offertetokens).

## Quote triggers

Wanneer de winkel de sales-module heeft ingeschakeld en offerteaanvragen aan heeft staan, dwingen deze condities een winkelwagen in de RFQ-flow. Anders gaat er geen trigger af, blijft `requires_quote` false en weigert de checkout nooit met `QUOTE_REQUIRED`. Ze verschijnen live op elke winkelwagenrespons als `requires_quote` en `quote_reasons`:

| Reden | Gaat af wanneer |
| --- | --- |
| `cart_threshold` | Het winkelwagentotaal bereikt het geconfigureerde drempelbedrag, of de som van de hoeveelheden bereikt de drempelhoeveelheid. |
| `configurator_choice` | Een gekozen configurator-optiekeuze is gemarkeerd als offerteplichtig. |
| `customer_group` | De contactgroep van de klant staat op altijd offreren (een sales-instelling op de groep). |
| `product_purchasability` | Een product in de winkelwagen heeft `purchasability` `quote`. Zo'n product kan nog steeds aan een winkelwagen worden toegevoegd; de winkelwagen meldt dan deze reden en gaat via dit endpoint in plaats van de checkout. |

Een offerteaanvraag indienen is ook toegestaan voor winkelwagens die geen enkele trigger raken; de koper kan altijd om een aanbod vragen.

## De klantgerichte offertevorm

Het offerteaanvraag-endpoint en beide token-read-endpoints retourneren dezelfde envelope: `{"data": {...quote...}}`. Dit is de klantgerichte serialisatie: `merchant_note`, `cart_id`, `customer_id` en `contact_id` zijn nooit aanwezig, en het accept token (of de hash ervan) wordt nooit blootgesteld. Het offerte-object bevat ook `origin` (`"storefront"` voor een offerte die hier is aangemaakt), `intro`, `terms`, `signed_at`, `acceptance_mode`, `pdf_url` en `signed_pdf_url`, gedocumenteerd op de pagina [Offertetokens](/nl/api/orders-and-quotes/quote-tokens). Anders dan bij winkelwagen- en bestelresponses zijn alle offertebedragen kale integers in minor units, geen money-objecten. Datums zijn ISO-8601-strings.

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Offerte-id. |
| `number` | string | altijd | Klantgericht offertenummer, `"Q-"` plus een integer per winkel, bijvoorbeeld `"Q-17"`. |
| `status` | string | altijd | Zie de statustabel hierboven. |
| `email` | string | altijd | E-mailadres van de aanvrager. |
| `name` | string | altijd | Naam van de aanvrager. |
| `phone` | string of null | altijd | Telefoonnummer van de aanvrager. |
| `billing_address` | object of null | altijd | Zoals ingediend. |
| `shipping_address` | object of null | altijd | Zoals ingediend. |
| `currency` | string | altijd | `EUR`. |
| `customer_note` | string of null | altijd | De notitie van de koper uit de aanvraag. |
| `message_to_customer` | string of null | altijd | Het bericht van de merchant, gezet bij het aanbieden. |
| `valid_until` | string (ISO-8601) of null | altijd | Vervaldatum van het aanbod, gezet bij het aanbieden. |
| `subtotal_amount` | integer of null | altijd | Bevroren op het moment van aanbieden; null zolang `submitted`. Minor units. |
| `tax_total_amount` | integer of null | altijd | Bevroren op het moment van aanbieden; null zolang `submitted`. |
| `shipping_total_amount` | integer of null | altijd | Bevroren op het moment van aanbieden; null zolang `submitted`. |
| `total_amount` | integer of null | altijd | Bevroren op het moment van aanbieden; null zolang `submitted`. |
| `tax_breakdown` | array of null | altijd | Uitsplitsing per tarief `{"rate": float, "base": int, "tax": int}`; null zolang `submitted`. |
| `converted_order_id` | string (UUID) of null | altijd | De bestelling die bij acceptatie is aangemaakt, anders null. |
| `submitted_at` | string (ISO-8601) of null | altijd | Wanneer de aanvraag is ingediend. |
| `offered_at` | string (ISO-8601) of null | altijd | Wanneer de merchant het aanbod heeft verzonden. |
| `accepted_at` | string (ISO-8601) of null | altijd | Wanneer de koper heeft geaccepteerd. |
| `declined_at` | string (ISO-8601) of null | altijd | Wanneer de koper heeft afgewezen. |
| `cancelled_at` | string (ISO-8601) of null | altijd | Wanneer de merchant heeft geannuleerd. |
| `created_at` | string (ISO-8601) of null | altijd | Aanmaaktijd van de rij. |
| `updated_at` | string (ISO-8601) of null | altijd | Laatste wijzigingstijd. |
| `lines` | array | altijd | Offerteregels, zie hieronder. |

### `lines[]`

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Offerteregel-id. |
| `product_id` | string (UUID) of null | altijd | Het product; null voor custom regels die de merchant heeft toegevoegd. |
| `variant_id` | string (UUID) of null | altijd | De variant, indien van toepassing. |
| `is_custom` | boolean | altijd | True voor door de merchant toegevoegde custom regels. |
| `title` | string | altijd | Regeltitel (productnaam-snapshot, of vrije tekst voor custom regels). |
| `sku` | string of null | altijd | SKU-snapshot. |
| `quantity` | number | altijd | Hoeveelheid, overgenomen van de winkelwagenregel: een geheel getal voor een product dat per `piece` verkocht wordt, tot drie decimalen voor elke andere eenheid (bijvoorbeeld `0.75`). Dienst- en materiaalregels van de merchant kunnen ook decimalen hebben. |
| `indicative_unit_price` | integer of null | altijd | Stukprijs (minor units), gesnapshot uit de prijscontext van de aanvrager op het moment van indienen. |
| `quoted_unit_price` | integer of null | altijd | De geoffreerde stukprijs van de merchant; null totdat er geprijsd is. |
| `tax_class_id` | string (UUID) of null | altijd | Btw-klasse die de merchant heeft toegewezen, indien aanwezig. |
| `attributes` | object of null | altijd | Configuratie-snapshot voor configureerbare regels, anders null. |
| `sort_order` | integer | altijd | Weergavevolgorde. |
| `kind` | string | altijd | `product` voor regels die uit een winkelwagen zijn gemaakt; regels van de merchant kunnen `service`, `material` of `other` zijn. |
| `unit` | string of null | altijd | Eenheidslabel. Een regel uit een winkelwagen heeft `pcs` voor een product dat per `piece` verkocht wordt en anders de eenheid van het product (`kg`, `m2`, `hour`, ...). |
| `description` | string of null | altijd | Optionele notitie op regelniveau. |
| `tax_rate` | string of null | altijd | Decimaal btw-tarief, vastgelegd wanneer de merchant het aanbod verstuurt (bijvoorbeeld `"21.00"`); tot dan null. |
| `unit_price` | integer of null | altijd | Alias van `quoted_unit_price`. |

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

Dient de winkelwagen in als offerteaanvraag. Vereist `commerce:write`, een `active` niet-lege winkelwagen, en doorloopt de eigenaarschapscontrole van de winkelwagen. Bij succes wordt de winkelwagen `converted`, wordt een offerte aangemaakt in status `submitted` waarbij elke winkelwagenregel wordt gesnapshot tegen een indicatieve stukprijs uit de prijscontext van de aanvrager, wordt een ontvangstbevestigingsmail voor de aanvrager in de wachtrij gezet, en wordt de klantgerichte offerte geretourneerd met status 201.

Contactresolutie: `email` en `name` zijn in totaal verplicht, maar elk kan komen uit ofwel de request body ofwel, wanneer een geldige `X-Customer-Token` wordt meegestuurd, het profiel van de klant. Een geldig token koppelt de offerte bovendien aan de klant.

### Parameters

**Pad**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | ja | Winkelwagen-id. Moet een `active`, niet-lege winkelwagen zijn. |

**Body**

Alle body-velden zijn optioneel en nullable; `email` en `name` worden verplicht wanneer het klantprofiel ze niet kan leveren.

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `email` | string | conditioneel | Geldig e-mailadres. Valt terug op het profiel-e-mailadres van de klant met token. |
| `name` | string | conditioneel | Naam van de aanvrager. Valt terug op de naam uit het klantprofiel. |
| `phone` | string | nee | Contacttelefoonnummer. |
| `billing_address` | object | nee | Vrij adresobject. |
| `shipping_address` | object | nee | Vrij adresobject. |
| `customer_note` | string | nee | Bericht aan de merchant. |

**Headers**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | nee | Indien aanwezig moet deze herleidbaar zijn, anders 401. Koppelt de offerte aan de klant, levert contact-fallbacks, en selecteert de prijscontext voor indicatieve prijzen. |

```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
  }
}
```

Na het indienen is er geen storefront-endpoint om de offerte op id te pollen: de koper ontvangt het aanbod per e-mail, en de token-endpoints nemen het vanaf daar over.

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 404 | `{"error": "Module not enabled: sales"}` | De winkel heeft de sales-module niet ingeschakeld. Let op de key `error` en de status 404 (andere module-gates antwoorden 403). |
| 404 | `{"message": "..."}` | Offerteaanvragen zijn uitgeschakeld voor de winkel, of de winkelwagen is onbekend, verlopen, of niet `active`. |
| 401 | `{"message": "Invalid customer token."}` | `X-Customer-Token` aanwezig maar niet herleidbaar. |
| 422 | `{"message": "...", "errors": {"email": ["Email is required."], "name": ["Name is required."]}}` | Noch de body noch het klantprofiel levert e-mailadres of naam. |
| 422 | `{"message": "...", "errors": {"cart": ["Cart is empty."]}}` | De winkelwagen heeft geen regels. |
| 403 | eigenaarschaps-, scope-, commerce-module- of suspensie-body | Zie de pagina Winkelwagens. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit overschreden. |
