# 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.

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_NOT_OFFERED`.

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

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

Fetches the quote for this token. Requires `commerce:read`. Returns the customer-facing quote serialization (no `merchant_note`, `cart_id`, or `customer_id`; the token or its hash is never in the body). The full field reference is on the Quote requests page. 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/commerce/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",
    "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,
        "tax_class_id": null,
        "attributes": null,
        "sort_order": 0
      }
    ]
  }
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Unknown token, or a token belonging to another store. |
| 403 | scope, module, or suspension body | Key lacks `commerce:read`, module disabled, or store suspended. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

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

Accepts an offered quote, converting it into an order in status `pending_payment` at the quoted prices. Requires `commerce:write`. The operation is row-locked and idempotent: accepting an already-accepted quote returns the same order again. Before creating the order it 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 checks and records stock for every non-custom product line, refusing with `OUT_OF_STOCK` on shortage. No request body.

The response is a bare object, not wrapped in `data`. `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. |

No body.

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

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/commerce/quotes/by-token/${token}/accept`,
  { method: "POST", headers: { Authorization: `Bearer ${apiKey}` } }
);
if (res.status === 409) {
  const err = await res.json();
  // err.code: "QUOTE_NOT_OFFERED" | "OFFER_CHANGED" | "OUT_OF_STOCK"
}
const { order_id, order_number } = await res.json();
```

```json title="Response"
{
  "order_id": "7f8a9b0c-1d2e-4f3a-b4c5-d6e7f8a9b0c1",
  "order_number": 1043
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 409 | `{"code": "QUOTE_NOT_OFFERED", "message": "Quote cannot be accepted in its current 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."}` | Recomputed totals no longer match the frozen offer figures. |
| 409 | `{"code": "OUT_OF_STOCK", "out_of_stock": [{"product_id": "...", "variant_id": null, "requested": 25, "available": 10}]}` | Insufficient stock for one or more product lines. |
| 404 | `{"message": "..."}` | Unknown token. |
| 403 | scope, module, or suspension body | Key lacks `commerce:write`, module disabled, or store suspended. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

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

Declines an offered quote, with an optional reason that is appended to the merchant's internal note. Requires `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.

### 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/commerce/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/commerce/quotes/by-token/{token}"
  }
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 409 | `{"code": "QUOTE_NOT_OFFERED", "message": "Quote cannot be accepted in its current 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 `commerce:write`, module disabled, or store suspended. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |
