borkoldocs

Orders & Quotes

Quote tokens

Fetch, accept, or decline an offered quote using the tokenized link from the offer email.

View as Markdown

When the merchant sends an offer, the buyer receives an email containing a plain 48-character accept token. Only the SHA-256 hash of that token is stored server-side, so the emailed link is the only credential; there is no way to re-derive it. The storefront hosts a quote page that takes the token from the link and drives these three endpoints. No X-Customer-Token is involved: the accept token itself is the proof of access.

A quote may originate either from a storefront cart or from the merchant writing it directly (see origin below) — these three endpoints serve both the same way. Each endpoint is available at two identical paths: /v1/api/sales/quotes/by-token/{token} and /v1/api/commerce/quotes/by-token/{token}. Both require the sales module to be enabled and accept an API key carrying either the sales:read/sales:write ability family or the older commerce:read/commerce:write family — see Scopes below. The examples on this page use the sales path; every one of them works identically on the commerce path.

The token page's behaviour depends on the store's acceptance mode (acceptance_mode, see the GET response): in click mode, accepting needs no further input; in signature mode, accepting requires a drawn signature. POST .../accept and POST .../decline are throttled to 30 requests per minute, keyed by the caller's IP.

All three endpoints lazily expire first: if the quote is offered and valid_until has passed, its status flips to expired before the request is processed. A read after expiry therefore returns "status": "expired", and accept or decline fail with QUOTE_EXPIRED.

A token that does not match any quote of the store returns 404.

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

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

Identical route, request and response, kept forever for integrations built before the module split.

Fetches the quote for this token. Requires sales:read (or commerce:read). Returns the customer-facing quote serialization (merchant_note, cart_id, customer_id, contact_id, document_type_id, template_id, billing_address_id, work_address_id and invoice_id are stripped; the token or its hash is never in the body). Quote amounts are bare integers in minor units, not money objects.

Parameters

Path

NameTypeRequiredDescription
tokenstringyesThe plain 48-character token from the offer email.
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, or other. lines[].unit is a unit label: on product lines the product's unit (pcs for a product sold by the piece, otherwise for example kg or m2), on other lines free text (for example hour). It may be null on lines written before units were recorded. lines[].description is an optional line-level note. lines[].tax_rate is the decimal tax rate snapshotted at send time (as a string, for example "21.00"), independent of tax_class_id. quantity is a number and may be fractional (for example 1.5), with at most three decimals; a line for a product sold by the piece is always a whole number.

scope_items[] lists the "what's included / what's not" bullets attached to the offer, each with a free-text category (for example included, excluded) and a label. intro and terms are the introduction and terms text as they were when the quote was sent (null if the store or the quote never set them). origin is "merchant" for a quote a merchant wrote directly, or "storefront" for one that started as a cart-based quote request. acceptance_mode is the store's current setting ("click" or "signature") and determines whether POST .../accept needs a signature. pdf_url is a temporary URL to the offer document, valid for 15 minutes, or null while no PDF has been rendered yet for this quote. signed_at is set the moment the quote is accepted (in both acceptance modes) and stays null until then. signed_pdf_url is always present in the response but is null until the quote is accepted; once accepted it is a temporary URL, valid for 15 minutes, to the countersigned document.

Errors

StatusBodyWhen
404{"message": "..."}Unknown token, or a token belonging to another store.
403scope, module, or suspension bodyKey lacks sales:read (or commerce:read), module disabled, or store suspended.
429{"message": "Too Many Attempts."}Rate limit exceeded.

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

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

Identical route, request and response, kept forever for integrations built before the module split.

Accepts an offered quote. What happens next depends on the tenant's on_accept setting: the quote becomes either an order in status pending_payment (order mode) or an invoice (invoice mode). Requires sales:write (or commerce:write). The operation is row-locked and idempotent: accepting an already-accepted quote in order mode returns the same order again.

In order mode, before creating the order the operation re-computes the totals and refuses with OFFER_CHANGED if they no longer equal the figures frozen at send time (for example after a merchant re-edit or a tax change), and it reserves stock for every product line through the product's line handler at the quoted price (a made-to-measure line deducts its components), refusing with OUT_OF_STOCK only on a real shortage. A line whose product can no longer be sold (withdrawn, capability switched off, configuration no longer valid) still converts, without stock movement, and is flagged to the merchant on the order. Fractional line quantities convert to the order as they are.

The response is the same customer-facing quote shape as GET .../by-token/{token}, wrapped in data, with order_id and order_number appended at the root of the envelope alongside data — not inside it. Both are null when the tenant's on_accept setting raises an invoice instead of an order. order_number is a plain integer. Fetch the created order with GET /v1/api/commerce/orders/{order_id} if you need the full order.

Parameters

Path

NameTypeRequiredDescription
tokenstringyesThe plain token from the offer email.

Body

NameTypeRequiredDescription
signaturestring or nullrequired when the store's acceptance_mode is signatureA data:image/png;base64, data URL of the drawn signature, at most 2 MB. The decoded bytes are re-validated as a real PNG.

A signature sent while acceptance_mode is click is ignored. Click-mode acceptance also produces a signed_pdf_url (the accepted document, without a drawn signature block) — a signature is not required to have a countersigned document.

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
}

Errors

StatusBodyWhen
422{"code": "SIGNATURE_REQUIRED", "message": "A signature is required to accept this quote."}The store requires a drawn signature and none was sent.
422{"code": "SIGNATURE_INVALID", "message": "..."}The signature is not a PNG data URL, larger than 2 MB, or does not decode to a real PNG.
409{"code": "QUOTE_NOT_OFFERED", "message": "Quote is not awaiting a response (status: declined)."}The quote is not currently offered (submitted, declined, expired, or cancelled; the status in the message reflects the actual one).
409{"code": "OFFER_CHANGED", "message": "This offer has changed since it was sent. Ask the merchant to re-send it."}Order mode only: recomputed totals no longer match the frozen offer figures.
409{"code": "OUT_OF_STOCK", "message": "Insufficient stock.", "out_of_stock": [{"product_id": "...", "variant_id": null, "requested": 25, "available": 10}]}Order mode only: insufficient stock for one or more product lines (or, for a made-to-measure line, its components). requested and available are JSON numbers and may be fractional.
409{"code": "ALREADY_CONVERTED", "message": "This quote was already converted to a document."}Order mode only, and only on a race: the quote was already accepted by another request without an order behind it yet.
410{"code": "QUOTE_EXPIRED", "message": "This quote is no longer valid."}The quote passed its validity; its status is flipped to expired.
404{"message": "..."}Unknown token.
403scope, module, or suspension bodyKey lacks sales:write (or commerce:write), module disabled, or store suspended.
429{"message": "Too Many Attempts."}More than 30 accept or decline requests in a minute, keyed by the caller's IP.

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

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

Identical route, request and response, kept forever for integrations built before the module split.

Declines an offered quote, with an optional reason that is appended to the merchant's internal note. Requires sales:write (or commerce:write). Row-locked against a racing accept. Returns the updated customer-facing quote (status declined, declined_at set) with status 200. A declined quote is not terminal: the merchant can still re-offer or cancel it. Throttled the same as accept: 30 requests per minute, keyed by the caller's IP.

Parameters

Path

NameTypeRequiredDescription
tokenstringyesThe plain token from the offer email.

Body

NameTypeRequiredDescription
reasonstring or nullnoWhy the buyer declined. Appended to the merchant's internal note; never echoed back in the customer-facing quote.
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}"
  }
}

Errors

StatusBodyWhen
409{"code": "QUOTE_NOT_OFFERED", "message": "Quote is not awaiting a response (status: accepted)."}The quote is not currently offered.
422{"message": "...", "errors": {"reason": ["..."]}}reason is present but not a string.
404{"message": "..."}Unknown token.
403scope, module, or suspension bodyKey lacks sales:write (or commerce:write), module disabled, or store suspended.
429{"message": "Too Many Attempts."}More than 30 accept or decline requests in a minute, keyed by the caller's IP.

Scopes

GET requires sales:read, and accept/decline require sales:write, on either path. commerce:read / commerce:write are accepted anywhere sales:read / sales:write is, for keys minted before the module split — there is no need to re-mint an existing key. There is no sales.view, sales.manage, or sales.settings scope: those names do not exist on this surface.