Bestellingen & offertes

Offertetokens

Een aangeboden offerte ophalen, accepteren of afwijzen met de tokenized link uit de aanbod-e-mail.

Bekijk als Markdown

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

NaamTypeVerplichtBeschrijving
tokenstringjaHet plain token van 48 tekens uit de aanbod-e-mail.
Request
curl https://api.borkol.com/v1/api/sales/quotes/by-token/{token} \
  -H "Authorization: Bearer {api_key}"
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

StatusBodyWanneer
404{"message": "..."}Onbekend token, of een token dat bij een andere winkel hoort.
403scope-, module- of suspensie-bodyKey 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

NaamTypeVerplichtBeschrijving
tokenstringjaHet plain token uit de aanbod-e-mail.

Body

NaamTypeVerplichtBeschrijving
signaturestring of nullverplicht wanneer de acceptance_mode van de winkel signature isEen 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.

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..."}'
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();
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

StatusBodyWanneer
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.
403scope-, module- of suspensie-bodyKey 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

NaamTypeVerplichtBeschrijving
tokenstringjaHet plain token uit de aanbod-e-mail.

Body

NaamTypeVerplichtBeschrijving
reasonstring of nullneeWaarom de koper heeft afgewezen. Toegevoegd aan de interne notitie van de merchant; wordt nooit teruggegeven in de klantgerichte offerte.
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."}'
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

StatusBodyWanneer
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.
403scope-, module- of suspensie-bodyKey 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.