# Offertetokens

Wanneer de merchant een aanbod verstuurt, ontvangt de koper een e-mail met een plain accept token van 48 tekens. Alleen de SHA-256-hash van dat token wordt server-side opgeslagen, dus de gemailde link is de enige credential; er is geen manier om het opnieuw af te leiden. De storefront host een offertepagina die het token uit de link haalt en deze drie endpoints aanstuurt. Er is geen `X-Customer-Token` bij betrokken: het accept token zelf is het toegangsbewijs.

Een offerte kan zowel ontstaan vanuit een storefront-winkelwagen als rechtstreeks door de merchant worden geschreven (zie `origin` hieronder) — deze drie endpoints bedienen beide gevallen op dezelfde manier. Elk endpoint is beschikbaar op twee identieke paden: `/v1/api/sales/quotes/by-token/{token}` en `/v1/api/commerce/quotes/by-token/{token}`. Beide vereisen dat de module `sales` is ingeschakeld en accepteren een API-key met **ofwel** de scope-familie `sales:read`/`sales:write` **ofwel** de oudere familie `commerce:read`/`commerce:write` — zie Scopes hieronder. De voorbeelden op deze pagina gebruiken het `sales`-pad; elk voorbeeld werkt identiek op het `commerce`-pad.

Het gedrag van de tokenpagina hangt af van de acceptatiemodus van de winkel (`acceptance_mode`, zie de GET-respons): in modus `click` is geen verdere invoer nodig om te accepteren; in modus `signature` is een getekende handtekening vereist om te accepteren. `POST .../accept` en `POST .../decline` zijn beperkt tot **30 requests per minuut**, gesleuteld op het IP-adres van de aanroeper.

Alle drie de endpoints laten eerst lazy verlopen: als de offerte `offered` is en `valid_until` is verstreken, klapt de status om naar `expired` voordat het request wordt verwerkt. Een read na verloop retourneert daarom `"status": "expired"`, en accepteren of afwijzen faalt met `QUOTE_EXPIRED`.

Een token dat met geen enkele offerte van de winkel matcht, retourneert 404.

## GET /v1/api/sales/quotes/by-token/{token}

### Alias: `/v1/api/commerce/quotes/by-token/{token}`

Identieke route, request en response, permanent behouden voor integraties die zijn gebouwd vóór de module-splitsing.

Haalt de offerte voor dit token op. Vereist `sales:read` (of `commerce:read`). Retourneert de klantgerichte offerteserialisatie (`merchant_note`, `cart_id`, `customer_id`, `contact_id`, `document_type_id`, `template_id`, `billing_address_id`, `work_address_id` en `invoice_id` worden verwijderd; het token of de hash ervan staat nooit in de body). Offertebedragen zijn kale integers in minor units, geen money-objecten.

### Parameters

**Pad**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `token` | string | ja | Het plain token van 48 tekens uit de aanbod-e-mail. |

```bash title="Request"
curl https://api.borkol.com/v1/api/sales/quotes/by-token/{token} \
  -H "Authorization: Bearer {api_key}"
```

```json title="Response"
{
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "offered",
    "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": "Volume price applied.",
    "valid_until": "2026-08-31T23:59:59+00:00",
    "subtotal_amount": 44421,
    "tax_total_amount": 9329,
    "shipping_total_amount": 0,
    "total_amount": 53750,
    "tax_breakdown": [{ "rate": 21, "base": 44421, "tax": 9329 }],
    "converted_order_id": null,
    "submitted_at": "2026-08-14T10:15:30+00:00",
    "offered_at": "2026-08-15T09:00:00+00:00",
    "accepted_at": null,
    "declined_at": null,
    "cancelled_at": null,
    "created_at": "2026-08-14T10:15:30+00:00",
    "updated_at": "2026-08-15T09:00:00+00:00",
    "origin": "merchant",
    "intro": "Thanks for the opportunity to quote this project.",
    "terms": "Prices valid for 30 days. 50% deposit on acceptance.",
    "acceptance_mode": "click",
    "pdf_url": "https://storage.borkol.com/private/....pdf?signature=...",
    "signed_at": null,
    "signed_pdf_url": null,
    "scope_items": [
      {
        "id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
        "category": "included",
        "label": "Delivery within 10 business days",
        "included": true,
        "sort_order": 0
      }
    ],
    "media_ids": ["a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"],
    "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": 2150,
        "unit_price": 2150,
        "tax_class_id": null,
        "attributes": null,
        "sort_order": 0,
        "kind": "product",
        "unit": "pcs",
        "description": null,
        "tax_rate": "21.00"
      }
    ]
  }
}
```

`lines[].kind` is `product`, `service`, `material`, of `other`. `lines[].unit` is een eenheidslabel: op productregels de eenheid van het product (`pcs` voor een product dat per stuk verkocht wordt, anders bijvoorbeeld `kg` of `m2`), op andere regels vrije tekst (bijvoorbeeld `hour`). Het kan `null` zijn op regels die zijn geschreven voordat eenheden werden vastgelegd. `lines[].description` is een optionele notitie op regelniveau. `lines[].tax_rate` is het decimale btw-tarief zoals vastgelegd bij verzending (als string, bijvoorbeeld `"21.00"`), los van `tax_class_id`. `quantity` is een getal en mag fractioneel zijn (bijvoorbeeld `1.5`), met ten hoogste drie decimalen; een regel voor een product dat per stuk verkocht wordt is altijd een geheel getal.

`scope_items[]` bevat de "wat is inbegrepen / wat niet" bullets van het aanbod, elk met een vrij-tekst `category` (bijvoorbeeld `included`, `excluded`) en een `label`. `intro` en `terms` zijn de introductie- en voorwaardentekst zoals die waren op het moment van verzenden (`null` als de winkel of de offerte deze nooit heeft ingesteld). `origin` is `"merchant"` voor een offerte die de merchant rechtstreeks heeft geschreven, of `"storefront"` voor een offerte die begon als een op een winkelwagen gebaseerde offerteaanvraag. `acceptance_mode` is de huidige instelling van de winkel (`"click"` of `"signature"`) en bepaalt of `POST .../accept` een `signature` nodig heeft. `pdf_url` is een tijdelijke URL naar het aanbodsdocument, 15 minuten geldig, of `null` zolang er nog geen PDF is gerenderd voor deze offerte. `signed_at` wordt gezet op het moment dat de offerte wordt geaccepteerd (in beide acceptatiemodi) en blijft daarvoor `null`. `signed_pdf_url` is altijd aanwezig in de respons maar is `null` totdat de offerte is geaccepteerd; eenmaal geaccepteerd is het een tijdelijke URL, 15 minuten geldig, naar het contra-getekende document.

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Onbekend token, of een token dat bij een andere winkel hoort. |
| 403 | scope-, module- of suspensie-body | Key mist `sales:read` (of `commerce:read`), module uitgeschakeld, of winkel geschorst. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit overschreden. |

## POST /v1/api/sales/quotes/by-token/{token}/accept

### Alias: `/v1/api/commerce/quotes/by-token/{token}/accept`

Identieke route, request en response, permanent behouden voor integraties die zijn gebouwd vóór de module-splitsing.

Accepteert een aangeboden offerte. Wat er vervolgens gebeurt hangt af van de tenant-instelling `on_accept`: de offerte wordt óf een bestelling in status `pending_payment` (bestelmodus), óf een factuur (factuurmodus). Vereist `sales:write` (of `commerce:write`). De operatie is row-locked en idempotent: het accepteren van een reeds geaccepteerde offerte in bestelmodus retourneert opnieuw dezelfde bestelling.

In bestelmodus worden vóór het aanmaken van de bestelling de totalen opnieuw berekend en wordt geweigerd met `OFFER_CHANGED` als ze niet meer gelijk zijn aan de bedragen die bij verzending zijn bevroren (bijvoorbeeld na een merchant-bewerking of een btw-wijziging), en wordt voor elke productregel voorraad gereserveerd via de line handler van het product tegen de geoffreerde prijs (een made-to-measure-regel boekt zijn componenten af), met een weigering `OUT_OF_STOCK` alleen bij een echt tekort. Een regel waarvan het product niet meer verkocht kan worden (ingetrokken, capability uitgeschakeld, configuratie niet meer geldig) wordt toch omgezet, zonder voorraadmutatie, en voor de merchant gemarkeerd op de bestelling. Fractionele regelhoeveelheden gaan ongewijzigd over naar de bestelling.

De respons heeft dezelfde klantgerichte offertevorm als `GET .../by-token/{token}`, verpakt in `data`, met `order_id` en `order_number` toegevoegd aan de basis van de envelope naast `data` — niet erin. Beide zijn `null` wanneer de `on_accept`-instelling van de tenant een factuur oplevert in plaats van een bestelling. `order_number` is een gewone integer. Haal de aangemaakte bestelling op met `GET /v1/api/commerce/orders/{order_id}` als je de volledige bestelling nodig hebt.

### Parameters

**Pad**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `token` | string | ja | Het plain token uit de aanbod-e-mail. |

**Body**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `signature` | string of null | verplicht wanneer de `acceptance_mode` van de winkel `signature` is | Een `data:image/png;base64,`-data-URL van de getekende handtekening, van hoogstens 2 MB. De gedecodeerde bytes worden opnieuw gevalideerd als een echte PNG. |

Een `signature` die wordt meegestuurd terwijl `acceptance_mode` `click` is, wordt genegeerd. Acceptatie in click-modus levert ook een `signed_pdf_url` op (het geaccepteerde document, zonder een blok met getekende handtekening) — een handtekening is niet vereist om een contra-getekend document te hebben.

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/sales/quotes/by-token/{token}/accept \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"signature": "data:image/png;base64,iVBORw0KGgo..."}'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/sales/quotes/by-token/${token}/accept`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ signature: null }),
  }
);
if (res.status === 409) {
  const err = await res.json();
  // err.code: "QUOTE_NOT_OFFERED" | "OFFER_CHANGED" | "OUT_OF_STOCK" | "ALREADY_CONVERTED"
}
if (res.status === 422) {
  const err = await res.json();
  // err.code: "SIGNATURE_REQUIRED" | "SIGNATURE_INVALID"
}
const { data: quote, order_id, order_number } = await res.json();
```

```json title="Response"
{
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "accepted",
    "...": "same customer-facing quote shape as GET /v1/api/sales/quotes/by-token/{token}"
  },
  "order_id": "7f8a9b0c-1d2e-4f3a-b4c5-d6e7f8a9b0c1",
  "order_number": 1043
}
```

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 422 | `{"code": "SIGNATURE_REQUIRED", "message": "A signature is required to accept this quote."}` | De winkel vereist een getekende handtekening en die is niet meegestuurd. |
| 422 | `{"code": "SIGNATURE_INVALID", "message": "..."}` | De handtekening is geen PNG-data-URL, groter dan 2 MB, of decodeert niet naar een echte PNG. |
| 409 | `{"code": "QUOTE_NOT_OFFERED", "message": "Quote is not awaiting a response (status: declined)."}` | De offerte is momenteel niet `offered` (`submitted`, `declined`, `expired` of `cancelled`; de status in het bericht weerspiegelt de werkelijke). |
| 409 | `{"code": "OFFER_CHANGED", "message": "This offer has changed since it was sent. Ask the merchant to re-send it."}` | Alleen bestelmodus: de opnieuw berekende totalen matchen niet langer de bevroren aanbodbedragen. |
| 409 | `{"code": "OUT_OF_STOCK", "message": "Insufficient stock.", "out_of_stock": [{"product_id": "...", "variant_id": null, "requested": 25, "available": 10}]}` | Alleen bestelmodus: onvoldoende voorraad voor een of meer productregels (of, bij een made-to-measure-regel, de componenten ervan). `requested` en `available` zijn JSON-getallen en kunnen decimalen hebben. |
| 409 | `{"code": "ALREADY_CONVERTED", "message": "This quote was already converted to a document."}` | Alleen bestelmodus, en alleen bij een race: de offerte werd al door een ander request geaccepteerd zonder dat er nog een bestelling achter zit. |
| 410 | `{"code": "QUOTE_EXPIRED", "message": "This quote is no longer valid."}` | De offerte heeft haar geldigheidsduur overschreden; de status klapt om naar `expired`. |
| 404 | `{"message": "..."}` | Onbekend token. |
| 403 | scope-, module- of suspensie-body | Key mist `sales:write` (of `commerce:write`), module uitgeschakeld, of winkel geschorst. |
| 429 | `{"message": "Too Many Attempts."}` | Meer dan 30 accept- of decline-requests per minuut, gesleuteld op het IP-adres van de aanroeper. |

## POST /v1/api/sales/quotes/by-token/{token}/decline

### Alias: `/v1/api/commerce/quotes/by-token/{token}/decline`

Identieke route, request en response, permanent behouden voor integraties die zijn gebouwd vóór de module-splitsing.

Wijst een aangeboden offerte af, met een optionele reden die wordt toegevoegd aan de interne notitie van de merchant. Vereist `sales:write` (of `commerce:write`). Row-locked tegen een gelijktijdige accept. Retourneert de bijgewerkte klantgerichte offerte (status `declined`, `declined_at` gezet) met status 200. Een afgewezen offerte is niet terminaal: de merchant kan nog opnieuw een aanbod doen of annuleren. Beperkt tot hetzelfde als accept: 30 requests per minuut, gesleuteld op het IP-adres van de aanroeper.

### Parameters

**Pad**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `token` | string | ja | Het plain token uit de aanbod-e-mail. |

**Body**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `reason` | string of null | nee | Waarom de koper heeft afgewezen. Toegevoegd aan de interne notitie van de merchant; wordt nooit teruggegeven in de klantgerichte offerte. |

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/sales/quotes/by-token/{token}/decline \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Found a better price elsewhere."}'
```

```json title="Response"
{
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "declined",
    "declined_at": "2026-08-16T08:30:00+00:00",
    "...": "same customer-facing quote shape as GET /v1/api/sales/quotes/by-token/{token}"
  }
}
```

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 409 | `{"code": "QUOTE_NOT_OFFERED", "message": "Quote is not awaiting a response (status: accepted)."}` | De offerte is momenteel niet `offered`. |
| 422 | `{"message": "...", "errors": {"reason": ["..."]}}` | `reason` is aanwezig maar geen string. |
| 404 | `{"message": "..."}` | Onbekend token. |
| 403 | scope-, module- of suspensie-body | Key mist `sales:write` (of `commerce:write`), module uitgeschakeld, of winkel geschorst. |
| 429 | `{"message": "Too Many Attempts."}` | Meer dan 30 accept- of decline-requests per minuut, gesleuteld op het IP-adres van de aanroeper. |

## Scopes

`GET` vereist `sales:read`, en `accept`/`decline` vereisen `sales:write`, op elk pad. `commerce:read` / `commerce:write` worden overal geaccepteerd waar `sales:read` / `sales:write` wordt geaccepteerd, voor keys die vóór de module-splitsing zijn aangemaakt — een bestaande key hoeft niet opnieuw te worden aangemaakt. Er bestaat geen `sales.view`, `sales.manage` of `sales.settings`-scope: die namen bestaan niet op dit oppervlak.
