# Quote tokens

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**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | string | yes | The plain 48-character token from the offer email. |

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

| Status | Body | When |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Unknown token, or a token belonging to another store. |
| 403 | scope, module, or suspension body | Key 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**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | string | yes | The plain token from the offer email. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `signature` | string or null | required when the store's `acceptance_mode` is `signature` | A `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.

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

### Errors

| Status | Body | When |
| --- | --- | --- |
| 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. |
| 403 | scope, module, or suspension body | Key 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**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | string | yes | The plain token from the offer email. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string or null | no | Why the buyer declined. Appended to the merchant's internal note; never echoed back in the customer-facing quote. |

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

### Errors

| Status | Body | When |
| --- | --- | --- |
| 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. |
| 403 | scope, module, or suspension body | Key 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.
