# Sell products priced by measurement

> Price a Shoplazza product by a size the shopper enters — fabric by the metre, wallpaper by the roll — with Ajax API line item properties, then read it back.

Let shoppers enter a size on the product page and have the price follow it — custom murals sold by the square metre, wallpaper by the roll length, fabric by the metre. Steps 1 to 6 use storefront JavaScript and the Ajax API, so the buying flow runs entirely in the theme. Only Step 7, reading the size back off the order, calls the Open API.

This is a worked example of line item properties. For the general rules — how properties are written, what the shopper sees, how to read them back — start with [Add custom properties to cart and orders](/docs/app/use-cases/product-merchandising/add-custom-properties).

## Background

A product's price on Shoplazza lives on the variant, and a variant price is fixed. That leaves merchants who sell by measurement stuck:

- The product page can show a calculated price ("6 m² × $19.90 = $119.40"), but that number is only text — it never reaches the cart.
- Adding to cart charges the variant price once, no matter what size the shopper entered.
- The size the shopper typed is lost, so the workshop that produces the item has nothing to work from.

All three come from the same place: nothing in the buying flow accepts a price the storefront calculated.

But one thing in that flow *is* under your control — **quantity**. If the variant price represents one small unit of measure instead of one finished item, then quantity can carry the measurement, and the platform's own arithmetic produces the right total. The size itself travels alongside as line item properties.

So the goal of this guide: **make the cart total match the size the shopper entered, and carry that size through to the order** — without touching the variant price at any point.

## How it works

The pattern has five stages:

1. **Model the price as a unit.** Set the variant price to one *billing unit* of measure — for example `$1.99` per `0.1 m²`, which is `$19.90` per m².
2. **Collect the measurement.** An App Block on the product page renders the size inputs and shows the running total.
3. **Convert to billing units.** `units = ceil(area / unitArea)`. Rounding up means the shopper is never undercharged.
4. **Intercept the buy buttons.** Capture-phase click handlers on the theme's own Add to cart and Buy now buttons, so the native flow never runs with `quantity: 1`.
5. **Submit.** Add to cart posts to `POST /{locale}/api/cart`; Buy now posts to `POST /{locale}/api/checkout/order`. Both carry `quantity` (the unit count) and `properties` (the measurements).

The measurements ride along as line item properties and land on the order as `custom_properties`, which is where your fulfilment system reads them.

```mermaid
flowchart TD
    A[Shopper enters width and height] --> B[Block calculates area]
    B --> C["units = ceil(area / unitArea)"]
    C --> D{Which button?}
    D -- Add to cart --> E["POST /api/cart<br/>quantity + properties"]
    D -- Buy now --> F["POST /api/checkout/order<br/>line_items[0] with quantity + properties"]
    E --> G[Cart shows units and the calculated total]
    F --> H[Redirect to checkout_url]
    G --> I["Order line: quantity = units,<br/>custom_properties = measurements"]
    H --> I
```

## Prerequisites

- A theme app extension of the **App Block** type — see [Build a theme extension](/docs/app/extensions/online-store/add-theme-extension). An App Block renders inside the page, so the merchant places it in the buy section from the theme editor; you do not have to guess at the theme's DOM.
- A product whose variant price equals one billing unit, with **inventory tracking turned off**. This is not optional — see the warning in Step 1.
- Familiarity with the [Cart Ajax API](/docs/theme/references/ajax-api/cart) and the [Checkout Ajax API](/docs/theme/references/ajax-api/checkout).

## Step 1: Set up the product

Pick a billing unit small enough to keep rounding error acceptable, and set the variant price to that unit.

For a mural sold at `$19.90` per m², a billing unit of `0.1 m²` gives a variant price of `$1.99`. A 2 m × 3 m mural is 6 m², which is 60 billing units, so the shopper pays `$1.99 × 60 = $119.40`.

The smaller the unit, the smaller the rounding error and the larger the quantity number. `0.01 m²` would be ten times more precise but would put `600` in the shopper's cart for the same mural.

:::warning
**Turn inventory tracking off for this product.** With tracking on, the stock number stops meaning "how many items are left" and starts meaning "how many billing units are left" — 6 m² needs 60 units in stock. Worse, the two buy paths fail differently:

- `POST /api/cart` rejects the request with HTTP 406 and `You can only add 10 pieces to the cart.`
- `POST /api/checkout/order` returns HTTP 200 and **silently clamps the quantity to whatever stock is left**. The shopper sees $119.40 for 6 m², the order is created for $19.90, and `custom_properties` still says `Area: 6.00 m²`. Nothing reports an error, and the workshop produces a 6 m² piece that was paid for as 1 m².

If the merchant needs stock control on this product, it has to be enforced somewhere other than the platform's inventory field.
:::

## Step 2: Render the size inputs

The App Block holds the inputs and the running total, and passes the product context to your script through a global.

```html title="blocks/area-pricing.liquid"
{% use "area-pricing.css" %}
{% use "area-pricing.js" %}

<div class="ap-root" data-ap-root>
  <label>Width (cm) <input type="number" min="1" step="1" value="200" data-ap-width></label>
  <label>Height (cm) <input type="number" min="1" step="1" value="300" data-ap-height></label>

  <div class="ap-summary">
    <span>Area: <b data-ap-area>—</b></span>
    <span>Billing units: <b data-ap-units>—</b></span>
    <span>Total: <b data-ap-total>—</b></span>
  </div>
  <p class="ap-note" data-ap-note></p>
</div>

<script>
  window.__areaPricing = {
    productId: {{ product.id | json }},
    // product.variants.first returns null on Shoplazza — index into the array instead
    variantId: {{ product.variants[0].id | json }},
    unitPrice: {{ product.variants[0].price | json }},   // string, e.g. "1.99"
    unitArea: {{ block.settings.unit_area | default: 0.1 | json }}
  };
</script>

{% schema %}
{
  "name": { "en-US": "Area pricing", "zh-CN": "按面积计价" },
  "settings": [
    {
      "type": "text",
      "id": "unit_area",
      "label": { "en-US": "Billing unit in m²", "zh-CN": "计价单位（m²）" },
      "default": "0.1"
    }
  ]
}
{% endschema %}
```

:::note
`product.variants.first` returns `null` here — use `product.variants[0]` (or a `{% for %}` loop with `limit: 1`). `product.variants[0].price` comes back as a string such as `"1.99"`, so parse it before doing arithmetic.
:::

This guide assumes a single-variant product, which is the usual shape for made-to-measure goods: the size is an input, not a variant. If your product does have variants, read the selected variant from the theme's own variant controls rather than from `variants[0]`.

## Step 3: Convert the measurement to billing units

Round **up**. A shopper entering 205 cm × 305 cm needs 6.2525 m², which is 62.525 billing units — and quantity has to be a whole number. Rounding up charges for 63 units, so the merchant is never short; rounding down or to nearest would sell material below cost.

```javascript title="assets/area-pricing.js"
var cfg = window.__areaPricing;
var UNIT_AREA = parseFloat(cfg.unitArea) || 0.1;   // m² covered by one unit of the variant price
var UNIT_PRICE = parseFloat(cfg.unitPrice) || 0;

function calc() {
  var w = parseFloat(document.querySelector('[data-ap-width]').value);
  var h = parseFloat(document.querySelector('[data-ap-height]').value);
  if (!(w > 0) || !(h > 0)) return null;

  var area = (w * h) / 10000;                  // cm² → m²
  var units = Math.ceil(area / UNIT_AREA);     // round up: never undercharge
  return { w: w, h: h, area: area, units: units, total: units * UNIT_PRICE };
}
```

Show the result as the shopper types, so the price they see is the price they will be charged:

```javascript
function render() {
  var r = calc();
  var root = document.querySelector('[data-ap-root]');
  root.querySelector('[data-ap-area]').textContent = r ? r.area.toFixed(2) + ' m²' : '—';
  root.querySelector('[data-ap-units]').textContent = r ? String(r.units) : '—';
  root.querySelector('[data-ap-total]').textContent = r ? r.total.toFixed(2) : '—';
}

document.querySelector('[data-ap-width]').addEventListener('input', render);
document.querySelector('[data-ap-height]').addEventListener('input', render);
render();
```

For 200 × 300 this shows `Area: 6.00 m²`, `Billing units: 60`, `Total: 119.40`.

## Step 4: Intercept the buy buttons

The theme's own Add to cart and Buy now buttons would submit `quantity: 1`. Bind a capture-phase click handler to each so your code runs first and the native handler never fires.

The risk here is not binding — it is **binding to the wrong button**. Hijacking a button you misidentified rewrites it into an add-to-cart action, and if that button was the cart drawer's Check out, shoppers can no longer place orders at all. So scope the search tightly: look only inside the product form for *this* product.

```javascript
// Scope to this product's form. Do NOT select on [product-id="<id>"] alone —
// other modules on the page (recently-viewed, for one) carry the same product-id
// and their buttons would be caught too.
function scopes() {
  var pid = cfg.productId;
  return [].slice.call(document.querySelectorAll(
    'spz-product-form[product-id="' + pid + '"], form[product-id="' + pid + '"]'
  ));
}

// Classify a button. data-track-source is the reliable signal on themes that set it;
// class names and label text are fallbacks for themes that do not.
function buyActionOf(el) {
  var src = (el.getAttribute('data-track-source') || '').toLowerCase();
  if (src === 'buy_now' || src === 'add_to_cart') return src;

  var cls = String(el.className || '');
  if (/buy[-_]?now/i.test(cls)) return 'buy_now';
  if (/add[-_]?to[-_]?cart/i.test(cls)) return 'add_to_cart';
  return null;
}

function bind() {
  scopes().forEach(function (scope) {
    var candidates = [].slice.call(scope.querySelectorAll('button, spz-atc, [type="submit"]'));
    // The scope element itself can be the target on some themes
    if (scope.matches('button, spz-atc, [type="submit"]')) candidates.unshift(scope);

    candidates.forEach(function (el) {
      var action = buyActionOf(el);
      if (!action || el.__apBound) return;
      el.__apBound = true;
      el.__apAction = action;
      el.addEventListener('click', onBuyClick, true);   // true = capture phase
    });
  });
}

bind();
// Themes re-render the buy section on variant change or quantity change,
// which replaces the button nodes. Re-bind when that happens.
new MutationObserver(bind).observe(document.body, { childList: true, subtree: true });
```

:::warning
Keep the scope test strict on the intercept path. A generic signal such as "has the theme's primary-button class" matches login buttons, empty-cart buttons, and the cart drawer's Check out — all of which live outside any product form. Hiding an extra button is recoverable; hijacking one is not.
:::

## Step 5: Add to cart

Post the unit count as `quantity`, and the measurements as `properties`.

Keys without a leading underscore are shown to the shopper; keys with one are hidden but still returned by the API (see [how the underscore prefix works](/docs/app/use-cases/product-merchandising/add-custom-properties#hide-from-shopper)). The example below uses `Width` / `Height` / `Area` as the visible keys; adjust them to the language your store sells in.

```javascript
function root_() {
  // Multi-language stores prefix paths with a locale — always build the URL from this
  return window.SHOPLAZZA.routes.root;
}

function buildProperties(r) {
  return {
    'Width':  r.w + ' cm',            // visible to the shopper
    'Height': r.h + ' cm',
    'Area':   r.area.toFixed(2) + ' m²',
    '_billing_unit_area': String(UNIT_AREA),   // hidden: for your fulfilment system
    '_billing_units': String(r.units)
  };
}

function addToCart(r) {
  return fetch(root_() + '/api/cart', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      product_id: cfg.productId,
      variant_id: cfg.variantId,
      quantity: r.units,              // billing units, not pieces
      properties: buildProperties(r),
      refer_info: { source: 'add_to_cart' }
    })
  }).then(function (res) {
    if (!res.ok) throw new Error('cart_add_failed ' + res.status);
    return res.json();
  });
}
```

Reading the cart back afterwards shows the line as the platform stored it:

```json
{
  "cart": {
    "item_count": 60,
    "total_price": "119.40",
    "line_items": [
      {
        "variant_id": "36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105",
        "quantity": "60",
        "price": "1.99",
        "properties": "{\"Width\":\"200 cm\",\"Height\":\"300 cm\",\"Area\":\"6.00 m²\",\"_billing_unit_area\":\"0.1\",\"_billing_units\":\"60\"}"
      }
    ]
  }
}
```

:::note
`quantity` comes back as a string, and `properties` comes back as a **JSON string** that you have to parse. On the order side the same data appears under `custom_properties` as an **object** — see Step 7. The two shapes differ, so handle each where you read it.
:::

Do not call any theme API to refresh the cart afterwards. Themes watch for the `POST /api/cart` request itself and open their cart drawer on their own.

## Step 6: Buy now

Buy now does not go through the cart. It creates a checkout session directly with `POST /{locale}/api/checkout/order` and redirects the shopper to the URL that comes back.

`line_items[]` accepts `properties` on each line, exactly like the cart endpoint does. Do not send a price — the server prices the line from the variant, which is the whole point of the pattern.

```javascript
function buyNow(r) {
  return fetch(root_() + '/api/checkout/order', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      refer_info: { source: 'buy_now' },
      line_items: [{
        product_id: cfg.productId,
        variant_id: cfg.variantId,
        quantity: r.units,
        note: '',
        properties: buildProperties(r)
      }]
    })
  }).then(function (res) {
    if (!res.ok) throw new Error('checkout_create_failed ' + res.status);
    return res.json();
  }).then(function (body) {
    var url = body.data && body.data.checkout_url;
    if (!url) throw new Error('checkout_url_missing');
    location.href = url;          // straight to checkout, cart untouched
  });
}
```

Response (excerpt) for a 6 m² mural:

```json
{
  "state": "success",
  "data": {
    "order_token": "2446407211694842140211",
    "checkout_url": "/checkout/2446407211694842140211",
    "prices": {
      "total_price": "119.40",
      "subtotal_price": "119.40",
      "currency_code": "USD"
    }
  }
}
```

The click handler that Step 4 bound routes to whichever of the two submitters applies:

```javascript
var busy = false;

function onBuyClick(e) {
  e.preventDefault();
  e.stopPropagation();          // the theme's own handler must not run
  if (busy) return;

  var r = calc();
  if (!r) { note('Please enter a width and a height.'); return; }

  busy = true;
  var request = e.currentTarget.__apAction === 'buy_now' ? buyNow(r) : addToCart(r);
  request.then(function () {
    busy = false;
    note('Added: ' + r.units + ' units, ' + r.area.toFixed(2) + ' m²');
  }, function (err) {
    busy = false;
    note('Could not add this size to the cart. Please try again.');
    console.warn('[area-pricing]', err);
  });
}
```

:::note
Creating a checkout session this way leaves the cart untouched, which is the behaviour shoppers expect from Buy now. Routing Buy now through the cart instead has two side effects worth avoiding: the theme opens its cart drawer on every `POST /api/cart`, and abandoning payment leaves the item sitting in the cart.
:::

## Step 7: Read the measurement from the order

This step runs on your server, not in the theme. It needs an app with OAuth and token storage — see [Develop a standalone app](/docs/app/getting-started/create-public-app/develop-app) — and the `order` access scope.

On the order, the properties you sent appear on each line item under `custom_properties` — an object, already parsed:

`GET /openapi/2026-01/orders/{id}`

```json
{
  "order": {
    "number": "JMW07973",
    "total_price": "125.37",
    "line_items": [
      {
        "quantity": 63,
        "price": "1.99",
        "custom_properties": {
          "Width": "205 cm",
          "Height": "305 cm",
          "Area": "6.25 m²",
          "_billing_unit_area": "0.1",
          "_billing_units": "63"
        }
      }
    ]
  }
}
```

The underscore-prefixed keys are present here even though no interface displays them. The same object arrives on the `orders/create` webhook, so a fulfilment integration can pick the measurements up without polling — see [Listen for order events](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events).

:::warning
The field is called `properties` when you write it and `custom_properties` when you read it back from an order. Reading `properties` on an order line returns nothing, with no error.
:::

## Verification

Work through these on a test store before shipping:

1. **Block placement** — open the theme editor on the product template, add the block to the buy section, and confirm the size inputs appear there on the storefront.
2. **Live total** — type 200 and 300. The block should read `Area: 6.00 m²`, `Billing units: 60`, `Total: 119.40`.
3. **Rounding** — type 205 and 305. Area is 6.2525 m², so billing units must be **63**, not 62.
4. **Add to cart** — click the theme's own Add to cart. The cart should hold one line at quantity 60 with a total of `119.40`, and the cart page should list Width, Height, and Area on separate rows. The two underscore keys must not appear.
5. **Buy now** — click the theme's own Buy now. You should land on the checkout page directly, with the cart still holding whatever it held before, and the checkout summary showing the unit count and the three measurements.
6. **Order** — complete the order and open it in the admin. The line reads `1.99 USD x 63`, the three measurements are listed one per row, and the underscore keys are absent from the page but present in the API response.
7. **No stray hijacks** — with the block active, open the cart drawer and click Check out. It must go to checkout, not add anything to the cart. This catches an over-broad button scope in Step 4.

## Trade-offs to weigh before you adopt this

Quantity carries the measurement, so anything the platform counts in quantity now counts billing units instead:

- **Quantity is visible.** The cart badge, the checkout summary, and the admin order page all show 60 rather than 1. The admin order header reads "63 items". Warehouse and packing documents will say the same.
- **Quantity-based promotions fire early.** A "buy 3, get $5 off" automatic discount applies to anything over 0.3 m² — verified on a test store: a single 6 m² mural triggered it and the total dropped from `$119.40` to `$114.40`. Scope quantity-tiered promotions to exclude these products.
- **Shipping rates that count pieces will multiply.** Rates based on order value are unaffected, since the value is correct. Check any piece-count or weight-based rate that could apply to these products.
- **Inventory tracking cannot be used.** See the warning in Step 1.

If a merchant needs quantity to keep its usual meaning — because their promotions, shipping, and warehouse all depend on it — this pattern is the wrong fit, and the measurement has to be priced some other way.

## Next steps

- [Cart Ajax API](/docs/theme/references/ajax-api/cart)
- [Checkout Ajax API](/docs/theme/references/ajax-api/checkout)
- [Build a theme extension](/docs/app/extensions/online-store/add-theme-extension)
- [Listen for order events](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events)
