borkoldocs

Build a Storefront

End-to-end walkthrough

Build a complete custom storefront against the Borkol API, from API key to catalog, cart, checkout, orders, B2B quotes, customer accounts, and webhooks.

View as Markdown

This page walks through every storefront capability of the Borkol API in the order you would build one: catalog browsing, product configuration, carts, discounts, checkout, order tracking, the B2B quote flow, customer accounts, and webhooks. Each step gives you working code and links to the reference page that documents every field. Given this page plus the reference chapters, you can build a complete storefront without reading anything else.

Base URL for all requests: https://api.borkol.com. Every storefront route lives under /v1/api and is authenticated with a tenant API key sent as Authorization: Bearer {api_key}. The key identifies the store, so no tenant header of any kind is needed. All money values are integers in minor units (cents) with a separate ISO 4217 currency field. All resource IDs are UUID strings (order numbers are plain integers). Timestamps are ISO 8601.

One rule before any code: the API key is a server-side secret. It authorizes cart writes, checkout, and order reads for the whole store. Never ship it to a browser or mobile app; put a thin server between the storefront UI and the Borkol API. The architecture page covers this pattern in detail.

Step 1: Get an API key and scopes

The merchant creates API keys in the client dashboard (app.borkol.com) under Settings > API keys. At creation they pick a name and a set of scopes; the plaintext key is shown exactly once and cannot be retrieved again. Keys never expire; they work until revoked.

Scopes are per-module read/write pairs. A key can only carry scopes for modules enabled on the store, plus tenant:read which is always grantable. For a full storefront, request a key with:

ScopeNeeded for
commerce:readProducts, categories, configurator schema and quotes, reading carts, shipping methods, orders, quote reads by token
commerce:writeCreating and mutating carts, checkout, quote requests, quote accept/decline
customers:readCustomer profile and address reads (only if you build accounts)
customers:writeRegistration, login, logout, profile and address writes (only if you build accounts)

stock:read and stock:write are optional: product responses already include a stock_status field, so most storefronts never call the stock module directly. objects:read is only needed if the store uses the Objects module for CMS-style content.

A request with a key that lacks the required scope fails with 403 {"message": "Invalid ability provided."}. A request to a module the store does not have enabled fails with 403 {"error": "Module not enabled: commerce"} (note the error key on that one envelope). Full details: Authentication and Errors and rate limits.

The rate limit is 120 requests per minute per store, across all keys of that store. Exceeding it returns 429 {"message": "Too Many Attempts."} with a Retry-After header.

Step 2: Health check and identity

GET /v1/ping is unauthenticated and verifies connectivity and the base URL. GET /v1/api/me is the canonical first authenticated call: it requires no scope, returns the store the key belongs to, and lists the scopes the key carries. Call it at boot to fail fast on a bad or revoked key.

Request (JS)
const ping = await fetch("https://api.borkol.com/v1/ping");
// { "status": "ok", "version": "v1" }

const me = await fetch("https://api.borkol.com/v1/api/me", {
  headers: { Authorization: `Bearer ${process.env.BORKOL_API_KEY}` },
});
const identity = await me.json();
Response
{
  "tenant": {
    "id": "9c4e7f0a-2b31-4d6e-8f0a-1d2c3b4a5e6f",
    "name": "Four Seasons Garden",
    "slug": "four-seasons-garden"
  },
  "scopes": ["tenant:read", "commerce:read", "commerce:write", "customers:read", "customers:write"]
}

A 401 {"message": "Unauthenticated."} means the key is missing, malformed, or revoked. A 403 {"message": "Tenant suspended.", "code": "tenant_suspended"} means the store is suspended; every /v1/api route returns this while suspension lasts, so treat it as a storefront-wide outage state.

Step 3: Fetch categories and products, render a catalog

Two endpoints drive the whole catalog. GET /v1/api/commerce/categories returns the full active category tree, nested, ordered by position, not paginated. GET /v1/api/commerce/products returns active products, newest first, 20 per page (fixed; there is no per_page, sort, or search parameter), optionally filtered to a category subtree with filter[category]={slug}. An unknown or inactive slug returns an empty page, not a 404.

Request (JS)
const headers = { Authorization: `Bearer ${apiKey}` };

const cats = await fetch("https://api.borkol.com/v1/api/commerce/categories", { headers });
const { data: tree } = await cats.json();

const prods = await fetch(
  "https://api.borkol.com/v1/api/commerce/products?filter[category]=verandas&page=1",
  { headers }
);
const { data: products, meta } = await prods.json();
// meta.current_page, meta.last_page, meta.total drive your pager
Response (products, abridged)
{
  "data": [
    {
      "id": "9c2f1e6a-8b4d-4f2a-9e11-3d5a7b9c0e21",
      "type": "simple",
      "purchasability": "direct",
      "sku": "TABLE-OAK",
      "slug": "oak-table",
      "name": "Oak Table",
      "description": "Solid oak table.",
      "status": "active",
      "price": { "amount": 54900, "currency": "EUR" },
      "price_display": { "mode": "inc" },
      "categories": [
        { "id": "7a1b3c5d-2e4f-4a6b-8c0d-1e2f3a4b5c6d", "name": "Tables", "slug": "tables", "is_active": true }
      ],
      "variants": [],
      "has_variants": false,
      "price_from": { "amount": 54900, "currency": "EUR" },
      "image": {
        "url": "https://cdn.borkol.com/t/9c2f/table.jpg",
        "variants": { "thumb": "https://wsrv.nl/?url=...&w=160&we=1&output=webp&q=80", "card": "https://wsrv.nl/?url=...&w=480&we=1&output=webp&q=80", "detail": "https://wsrv.nl/?url=...&w=1200&we=1&output=webp&q=80" },
        "alt": "Oak table"
      },
      "stock_status": "in_stock",
      "created_at": "2026-07-02T09:14:33.000000Z",
      "updated_at": "2026-08-01T15:20:11.000000Z"
    }
  ],
  "links": { "first": "...", "last": "...", "prev": null, "next": "..." },
  "meta": { "current_page": 1, "last_page": 3, "per_page": 20, "total": 47 }
}

Rendering notes:

  • Use the image.variants URLs (thumb 160w, card 480w, detail 1200w) for responsive images; url is the original.
  • price.amount can be null: stores can hide prices from guests. Render "log in for prices" in that case.
  • stock_status is one of in_stock, low_stock, out_of_stock, backorder, untracked, and is only present when the store tracks stock.
  • The index response omits options and the full images gallery; fetch GET /v1/api/commerce/products/{id} for the product detail page, which adds both plus meta_title/meta_description for SEO.
  • Products with has_variants: true need a variant picker built from options (names and values) matched against variants[].option_values.
  • Pass a logged-in customer's session token as X-Customer-Token on product requests to get that customer's pricing (group prices, B2B ex-VAT display, quantity_tiers). Without it, pricing is computed for a guest.

Field-by-field reference: Products and Categories.

Step 4: Configurable products: schema and price quotes

Products with type: "configurable" (a veranda, a made-to-measure awning) are not added to the cart with a plain product ID. First fetch the configuration schema, render a configurator UI from it, then price the visitor's selection with the quote endpoint. Both require only commerce:read.

GET /v1/api/commerce/configurator/products/{id}/schema returns three blocks: dimensions (keyed numeric inputs with min/max and per-choice constraint tightening), price_matrices (id, name, and the choice conditions under which each applies), and option_groups (single or multi select, required flag, choices with price deltas, requires/excludes rules, images, and requires_quote flags). It returns 404 unless the product is active and configurable.

POST /v1/api/commerce/configurator/products/{id}/quote prices one concrete selection. It is a pure calculation: nothing is created, nothing is written.

Request (JS)
const schemaRes = await fetch(
  `https://api.borkol.com/v1/api/commerce/configurator/products/${productId}/schema`,
  { headers }
);
const { data: schema } = await schemaRes.json();

const quoteRes = await fetch(
  `https://api.borkol.com/v1/api/commerce/configurator/products/${productId}/quote`,
  {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({
      dimensions: { width: 420, depth: 300 },
      choices: ["c1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c", "f6a7b8c9-d0e1-4f2a-b3c4-d5e6f7a8b9c0"],
    }),
  }
);
const { data: quote } = await quoteRes.json();
Response
{
  "data": {
    "total": 561500,
    "currency": "EUR",
    "breakdown": [
      { "label": "Base", "amount": 530000 },
      { "label": "LED lighting: LED set (6)", "amount": 31500 }
    ],
    "matrix": { "id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a", "name": "Glass roof" }
  }
}

Configurator UI rules your frontend must enforce (the API validates them all again on quote, with one 422 message per violated field):

  • Every dimension in the schema is required and must be an integer within its min/max. Selecting a choice listed in a dimension's constraints tightens that range.
  • At most one choice per single group; every required group must be covered; requires lists are OR within a target group and AND across groups; excludes are hard conflicts.
  • Re-quote on every selection change so the displayed price is always live. Debounce, but do not cache aggressively: quote responses depend on the exact selection.
  • A choice with requires_quote: true means this configuration should go through the B2B quote request flow (step 9) instead of direct checkout; the quote endpoint still prices it for display.
  • A 422 with errors.configuration = ["not_priceable"] means the merchant has not priced this region of the configuration space. Show a "request a quote" fallback.

Full algorithm (matrix selection, delta passes, rounding) and every 422 message: How the configurator works, Configuration schema, Price quote.

Step 5: Create a cart and add lines

Carts are server-side objects. Create one with POST /v1/api/commerce/carts (no body; requires commerce:write), store the returned id for the visitor (cookie or local storage keyed to your own session), and mutate it with the line endpoints. Carts are created with currency EUR, status active, and a 30-day expiry that every write extends.

Request (JS)
const cartRes = await fetch("https://api.borkol.com/v1/api/commerce/carts", {
  method: "POST",
  headers,
});
const { data: cart } = await cartRes.json(); // cart.id is the visitor's handle

// Simple or variable product: product_id (+ variant_id when has_variants)
await fetch(`https://api.borkol.com/v1/api/commerce/carts/${cart.id}/lines`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    product_id: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    variant_id: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
    quantity: 2,
  }),
});

// Configurable product: configuration instead of variant_id
await fetch(`https://api.borkol.com/v1/api/commerce/carts/${cart.id}/lines`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    product_id: "9c2f1e6a-8b4d-4f2a-9e11-3d5a7b9c0e21",
    quantity: 1,
    configuration: {
      dimensions: { width: 420, depth: 300 },
      choices: ["c1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c"],
    },
  }),
});

Every cart mutation returns the full cart (same shape as GET /v1/api/commerce/carts/{id}): lines with snapshots and money objects, totals with tax_breakdown, the tenant's policy block, requires_quote plus quote_reasons, and discount state. Re-render your cart UI from each response; never compute totals client-side.

Rules baked into the line endpoints:

  • variant_id is required when the product has active variants and forbidden otherwise. configuration is required for configurable products and forbidden otherwise. The 422 messages are explicit ("Choose a variant for this product.", "Configure this product before adding it.").
  • Identical product + variant + configuration merges into the existing line: quantity is summed and the unit price re-resolves at the new quantity tier.
  • PATCH .../lines/{lineId} with {"quantity": n} changes quantity; DELETE .../lines/{lineId} removes the line.
  • The policy block carries the store's limits: max_lines (default 100), max_quantity_per_line (default 100), min_order_value_amount, identity_mode (guest_allowed, login_required, guest_only), and audience. Limit violations return 422 {"message": "...", "code": "cart_constraint"}.
  • PATCH /v1/api/commerce/carts/{id} sets email and/or metadata (max 16 scalar keys). Setting the email binds the cart to a guest customer row and enables abandoned-cart recovery on the merchant side.
  • Write endpoints only accept carts in status active; a converted or merged cart returns 404 on writes (checkout is the one exception, see step 7).
  • If a cart is bound to a real customer account, every cart request must also carry that customer's X-Customer-Token or it fails with 403 {"message": "This cart belongs to a customer."}.

Reference: Carts, Cart lines, Claiming a cart.

Step 6: Discount codes and shipping methods

A cart carries at most one discount code. PUT /v1/api/commerce/carts/{id}/discount-code with {"code": "WELCOME10"} stores the code verbatim and never errors for an unusable code: instead, every cart response evaluates it and reports the result. Check discount_code_error (null or one of invalid, expired, min_order, not_eligible, usage_limit) and render discounts[] (each {id, name, code, kind, amount} with kind one of order, products, shipping, bogo). DELETE on the same URL removes the code.

Before checkout, list the shipping options with their effective price for this specific cart:

Request (JS)
const shipRes = await fetch(
  `https://api.borkol.com/v1/api/commerce/carts/${cart.id}/shipping-methods`,
  { headers }
);
const { data: methods } = await shipRes.json();
Response
{
  "data": [
    { "id": "3c4d5e6f-7a8b-4c9d-0e1f-2a3b4c5d6e7f", "name": "Express", "amount": 995, "free_over_amount": null, "effective_amount": 995 },
    { "id": "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a", "name": "Standard", "amount": 495, "free_over_amount": 5000, "effective_amount": 0 }
  ]
}

effective_amount is what this cart will actually pay: it drops to 0 when the cart's goods total reaches the method's free_over_amount. These amounts are bare integers in minor units, not money objects. Reference: Discount codes, Shipping methods.

Step 7: Checkout to an order

POST /v1/api/commerce/carts/{id}/checkout converts the cart into an order in status pending_payment. It re-validates every line, re-resolves every price, re-checks stock, enforces the store's cart policy, evaluates B2B quote triggers, and locks discount usage. shipping_method_id is required exactly when the cart contains shippable lines (product types simple, variable, configurable) and the store has at least one active shipping method; carts of only digital goods skip it.

Request (JS)
const res = await fetch(`https://api.borkol.com/v1/api/commerce/carts/${cart.id}/checkout`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    email: "jane@example.com",
    billing_address: {
      name: "Jane Doe",
      street: "Keizersgracht 1",
      postal_code: "1015 CC",
      city: "Amsterdam",
      country: "NL",
    },
    shipping_address: null,
    shipping_method_id: "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a",
  }),
});

if (res.status === 201 || res.status === 200) {
  const { data: order } = await res.json(); // order.id, order.number, order.status
} else if (res.status === 409) {
  const err = await res.json();
  if (err.code === "QUOTE_REQUIRED") {
    // route the buyer to the quote request flow (step 9); err.reasons explains why
  } else {
    // "Cart contents changed.": err.cart is the refreshed cart (lines carry
    // "unavailable": true where relevant), err.out_of_stock and
    // err.discount_unavailable list the specifics when applicable.
    // Re-render the cart from err.cart and let the buyer retry.
  }
} else if (res.status === 422) {
  const err = await res.json();
  // err.code (when present): login_required | guest_only | b2b_approval_required
  // | b2c_only | min_order_value | cart_constraint. Otherwise field validation
  // in err.errors ("Cart is empty.", "Choose a valid shipping method.", addresses).
}

Body requirements: email (required, valid email), billing_address (required object with required name, street, postal_code, city, and country as an exact 2-character ISO code), shipping_address (optional, nullable, free-form), shipping_method_id (UUID, required only under the condition above). The response is the full order: integer number, money-object totals, tax_breakdown, per-line tax, shipping cost and name.

Checkout is idempotent: re-POSTing for an already-converted cart returns the existing order with 200 (first success is 201), so a network retry can never create a duplicate order. If a valid X-Customer-Token accompanies checkout, the order is bound to that customer and appears in their order history; an invalid token is a hard 401 {"message": "Invalid customer token."}.

Every error shape and the full order field reference: Checkout and Orders.

Step 8: Order status and order history

Orders move through a fixed state machine, driven entirely by the merchant: pending_payment to paid or cancelled; paid to shipped, cancelled, or refunded; shipped to completed or refunded; completed to refunded; cancelled and refunded are terminal. The storefront only reads status.

Two read paths:

  • GET /v1/api/commerce/orders/{id} fetches one order by UUID with commerce:read. It has no customer-ownership check; the API key alone authorizes it. Your storefront server must verify the requester is entitled to see that order (for example, it came from their own session's checkout) before proxying this call.
  • GET /v1/api/commerce/my/orders is the safe "my orders" page: it requires X-Customer-Token and returns only that customer's orders, newest first, paginated (per_page 1 to 100, default 25).
Request (JS)
// Confirmation page polling (your server verified ownership)
const orderRes = await fetch(`https://api.borkol.com/v1/api/commerce/orders/${orderId}`, { headers });
const { data: order } = await orderRes.json();
// order.status, order.tracking_carrier, order.tracking_number

// Account order history
const myOrders = await fetch("https://api.borkol.com/v1/api/commerce/my/orders?per_page=25", {
  headers: { ...headers, "X-Customer-Token": customerToken },
});
const { data: orders, meta } = await myOrders.json();

Polling an order status page every few seconds is wasteful against the 120 requests per minute budget; poll on page load and after user action, and use webhooks (step 11) to drive anything real-time. Reference: Orders.

Step 9: The B2B path: quote request, tokenized accept and decline

Stores with quotes enabled can force or offer an RFQ (request for quotation) path. The cart tells you when: requires_quote: true with machine-readable quote_reasons (cart_threshold, configurator_choice, customer_group, product_purchasability). Checkout on such a cart refuses with 409 {"code": "QUOTE_REQUIRED", "reasons": [...]}, so route the buyer to a quote request form instead of the payment step. Products with purchasability: "quote" and configurator choices flagged requires_quote should route there directly from the product page.

POST /v1/api/commerce/carts/{id}/quote-request submits the cart as a quote (status submitted, number like Q-17) and converts the cart. email and name are required unless a valid X-Customer-Token supplies them from the customer profile. If the store has quotes disabled, the endpoint is a 404.

Request (JS)
const rfq = await fetch(`https://api.borkol.com/v1/api/commerce/carts/${cart.id}/quote-request`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    email: "buyer@corp.example",
    name: "Bob Buyer",
    customer_note: "Need delivery before October.",
  }),
});
const { data: quote } = await rfq.json(); // quote.number: "Q-17", quote.status: "submitted"

The merchant then prices the quote and sends an offer. The buyer receives an email with a link containing a plain 48-character accept token (only its SHA-256 hash is stored server-side). Your storefront hosts the page that link points at and drives three token endpoints; the token itself is the credential, no X-Customer-Token involved:

  • GET /v1/api/commerce/quotes/by-token/{token} fetches the customer-facing quote (commerce:read): frozen totals as bare integer amounts, valid_until, lines with quoted_unit_price.
  • POST /v1/api/commerce/quotes/by-token/{token}/accept (commerce:write, no body) converts it into an order at the quoted prices and returns a bare {"order_id": "...", "order_number": 1043}. Idempotent: accepting again returns the same order. Refuses with 409 QUOTE_NOT_OFFERED (not currently offered, including lazily expired past valid_until), OFFER_CHANGED (recomputed totals no longer match the frozen offer), or OUT_OF_STOCK (with an out_of_stock array).
  • POST /v1/api/commerce/quotes/by-token/{token}/decline with an optional {"reason": "..."} marks it declined.

Quote lifecycle: submitted to offered (merchant sends, totals frozen) to accepted or declined; an offered quote past valid_until flips to expired on the next touch; the merchant can cancel. Reference: Quote requests and Quote tokens.

Step 10: Customer accounts

The Customers module adds registration, login, profiles, and address books. It introduces a second credential next to your API key: the customer session token, returned by register and login and sent as the X-Customer-Token header. The API key authenticates your storefront server to the store; the customer token identifies which shopper the request acts for.

Request (JS)
const login = await fetch("https://api.borkol.com/v1/api/customers/login", {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ email: "jane@example.com", password: "s3cret-pass" }),
});
const { data: customer, token: customerToken } = await login.json();

// Attach the anonymous cart to the account (merges any older active cart in)
await fetch(`https://api.borkol.com/v1/api/commerce/carts/${cart.id}/claim`, {
  method: "POST",
  headers: { ...headers, "X-Customer-Token": customerToken },
});

What the customer token does across the platform:

  • Product and cart reads price for the customer's group (tier prices, B2B ex-VAT display).
  • POST /v1/api/commerce/carts/{id}/claim binds an anonymous cart to the account and merges the customer's previous active cart into it.
  • Checkout with the token binds the order to the account, which is what populates GET /v1/api/commerce/my/orders.
  • Profile and addresses live under GET/PATCH /v1/api/customers/me and GET/POST/PATCH/DELETE /v1/api/customers/me/addresses; use the address book to prefill checkout.

Registration upgrades guest rows in place: if the email was already captured on a cart (step 5), the new account keeps the same customer id and its order history. register and login carry an extra limit of 10 requests per minute per IP on top of the store-wide limit, and both require customers:write. Store the customer token server-side in your own session; it is a bearer credential.

Reference: Customer accounts, Registration and sessions, Profile, Addresses, Password reset.

Step 11: Webhooks for order events

Polling tells you what changed when you ask; webhooks tell you when it happens. The merchant configures endpoints in the client dashboard (Settings > Webhooks) with a URL, a subscribed event list, and a whsec_-prefixed secret shown once at creation. Borkol POSTs to your endpoint with this envelope:

Delivery body
{
  "event": "order.created",
  "created_at": "2026-08-14T09:12:44+00:00",
  "data": {
    "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "number": "1042",
    "status": "pending_payment",
    "total_amount": 15900,
    "currency": "EUR",
    "email": "buyer@example.com"
  }
}

Subscribable events: order.created and order.status_changed (both with the order data shape above) and stock.adjusted ({product_id, variant_id, quantity} with the new absolute quantity). A ping event exists for the dashboard's "Send test" button.

Every delivery carries X-Borkol-Event (the event name) and X-Borkol-Signature, a hex HMAC-SHA256 of the exact raw request body computed with the endpoint secret. Verify before trusting:

Signature verification (Node)
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, signatureHeader, secret) {
  const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signatureHeader, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

Respond with any 2xx within 10 seconds. Anything else (or a timeout) is retried: 3 attempts total with backoff of 60 seconds then 300 seconds, after which the delivery is marked failed. Do the real work (cache invalidation, email, fulfillment sync) after responding, on a queue. Typical storefront uses: flip the order confirmation page to "paid" via order.status_changed, and invalidate a cached product page via stock.adjusted.

Full payload and delivery reference: Webhooks.

Where to go next

  • Storefront architecture: the server-side proxy pattern, caching, idempotency, and environment conventions.
  • Errors and rate limits: the complete error catalog your proxy should map.
  • Availability: bulk stock lookups, if stock_status on products is not enough.
  • Objects: CMS-style content types and records for merchant-managed pages.