Sell products priced by measurement
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.
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:
- Model the price as a unit. Set the variant price to one billing unit of measure — for example
$1.99per0.1 m², which is$19.90per m². - Collect the measurement. An App Block on the product page renders the size inputs and shows the running total.
- Convert to billing units.
units = ceil(area / unitArea). Rounding up means the shopper is never undercharged. - 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. - Submit. Add to cart posts to
POST /{locale}/api/cart; Buy now posts toPOST /{locale}/api/checkout/order. Both carryquantity(the unit count) andproperties(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.
Prerequisites
- A theme app extension of the App Block type — see Build a 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 and the Checkout Ajax API.
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.
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/cartrejects the request with HTTP 406 andYou can only add 10 pieces to the cart.POST /api/checkout/orderreturns 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, andcustom_propertiesstill saysArea: 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.
{% 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 %}
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.
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:
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.
// 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 });
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). The example below uses Width / Height / Area as the visible keys; adjust them to the language your store sells in.
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:
{
"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\"}"
}
]
}
}
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.
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:
{
"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:
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);
});
}
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 — 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}
{
"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.
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:
- 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.
- Live total — type 200 and 300. The block should read
Area: 6.00 m²,Billing units: 60,Total: 119.40. - Rounding — type 205 and 305. Area is 6.2525 m², so billing units must be 63, not 62.
- 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. - 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.
- 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. - 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.40to$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.