# End-to-end walkthrough

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](/storefront-guide/architecture) 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:

| Scope | Needed for |
| --- | --- |
| `commerce:read` | Products, categories, configurator schema and quotes, reading carts, shipping methods, orders, quote reads by token |
| `commerce:write` | Creating and mutating carts, checkout, quote requests, quote accept/decline |
| `customers:read` | Customer profile and address reads (only if you build accounts) |
| `customers:write` | Registration, 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](/getting-started/authentication) and [Errors and rate limits](/getting-started/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.

```javascript title="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();
```

```json title="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.

```javascript title="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
```

```json title="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](/catalog/products) and [Categories](/catalog/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.

```javascript title="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();
```

```json title="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](/configurator/index), [Configuration schema](/configurator/schema), [Price quote](/configurator/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.

```javascript title="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](/carts-and-checkout/carts), [Cart lines](/carts-and-checkout/cart-lines), [Claiming a cart](/carts-and-checkout/claim).

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

```javascript title="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();
```

```json title="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](/carts-and-checkout/discounts), [Shipping methods](/carts-and-checkout/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.

```javascript title="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](/carts-and-checkout/checkout) and [Orders](/orders-and-quotes/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).

```javascript title="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](/orders-and-quotes/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.

```javascript title="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](/orders-and-quotes/quote-requests) and [Quote tokens](/orders-and-quotes/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.

```javascript title="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](/customers/index), [Registration and sessions](/customers/accounts), [Profile](/customers/profile), [Addresses](/customers/addresses), [Password reset](/customers/passwords).

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

```json title="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:

```javascript title="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](/webhooks/index).

## Where to go next

- [Storefront architecture](/storefront-guide/architecture): the server-side proxy pattern, caching, idempotency, and environment conventions.
- [Errors and rate limits](/getting-started/errors-and-rate-limits): the complete error catalog your proxy should map.
- [Availability](/stock/availability): bulk stock lookups, if `stock_status` on products is not enough.
- [Objects](/objects/index): CMS-style content types and records for merchant-managed pages.
