Add custom properties to cart and orders
Attach information a shopper enters on the product page — engraving text, a size, a gift message, an internal tracking id — to the cart line, carry it through checkout, and read it back off the order. Everything up to the order runs in storefront JavaScript with the Ajax API; reading it back uses the Open API.
Background
Products and variants are defined by the merchant in advance. Anything the shopper types in at the moment of buying has no field to live in:
- Personalisation — engraving text, a name on a jersey, a gift message — is collected on the product page and then has nowhere to go.
- Dimensions and other made-to-measure inputs can be shown in the page but never reach the cart.
- Identifiers your own systems need — an A/B experiment group, a design file id from an external customiser — cannot be pinned to the purchase.
All of these are the same gap: the buying flow has no slot for shopper-supplied data.
Line item properties are that slot. Every line in the cart can carry a set of key-value pairs. They ride with the line through checkout, land on the order, and are returned by the Order API and order webhooks. The goal of this guide is to show you how to write them, what the shopper and the merchant will see, how to read them back, and what they cannot do.
How it works
- Write. Send
propertiesas an object on Add a variant to the cart or inline_items[]on Create a checkout session. Lines already in the cart can be updated with Set the quantity of one line item. - Carry. The cart stores the properties on the line item. Checkout copies them onto the order line.
- Display. Keys without a leading underscore are shown on the cart page, on checkout, in the shopper's order detail, and in the admin order detail. Keys with a leading underscore are hidden everywhere.
- Read back. The order exposes them as
line_items[].custom_properties— through Get order and on theorders/createwebhook.
Decide how to use it
Four questions settle most designs:
| Question | Answer |
|---|---|
| Should the shopper see this value? | Yes → plain key (Engraving). No → underscore prefix (_design_id). See Step 4. |
| Should it change the price? | Properties never affect price. For a fixed surcharge per option, use the Product Customizer app. For a price that scales with a measurement, see Sell products priced by measurement. |
| Do you need to find orders by this value? | Not directly — properties are not searchable. Copy the value into an order tag from your backend. See Step 6. |
| Is the value known at the moment of Add to cart? | If yes, send it then (Step 1 or 2). If it is decided later, or you are patching lines the shopper added through the theme's own button, use Step 3. |
Prerequisites
- A theme app extension — either an App Block placed in the buy section or an App Embed Block that injects a script. See Build a theme extension.
- Familiarity with the Cart Ajax API. All storefront calls below use
window.SHOPLAZZA.routes.rootto build the URL, so they work on multi-language stores. - For Step 5 and Step 6 only: an app with OAuth and token storage — see Develop a standalone app — with the
orderaccess scope.
Step 1: Write properties on Add to cart
Include properties in the same request that adds the line. The value is a plain object of string keys and string values.
fetch(window.SHOPLAZZA.routes.root + '/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
product_id: '99fa5fb4-f9dd-4db4-998f-011b4d0ff72d',
variant_id: '36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105',
quantity: 1,
properties: {
'Engraving': 'Happy Birthday', // shown to the shopper
'_design_id': 'dsg_8f21c' // hidden; for your own systems
},
refer_info: { source: 'add_to_cart' }
})
});
Reading the cart back with Get the cart shows how the line was stored:
{
"cart": {
"line_items": [
{
"variant_id": "36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105",
"quantity": "1",
"properties": "{\"Engraving\":\"Happy Birthday\",\"_design_id\":\"dsg_8f21c\"}"
}
]
}
}
In the cart, properties comes back as a JSON string, not an object; parse it before reading keys. The parameter is listed in the Add a variant to the cart reference.
Step 2: Write properties on Buy now
Buy now bypasses the cart and creates a checkout session directly. Each entry in line_items[] takes properties in exactly the same shape.
fetch(window.SHOPLAZZA.routes.root + '/api/checkout/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
refer_info: { source: 'buy_now' },
line_items: [{
product_id: '99fa5fb4-f9dd-4db4-998f-011b4d0ff72d',
variant_id: '36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105',
quantity: 1,
note: '',
properties: {
'Engraving': 'Happy Birthday',
'_design_id': 'dsg_8f21c'
}
}]
})
})
.then(function (res) { return res.json(); })
.then(function (body) { location.href = body.data.checkout_url; });
Response (excerpt):
{
"state": "success",
"data": {
"order_token": "2446407211694842140211",
"checkout_url": "/checkout/2446407211694842140211"
}
}
Like the cart endpoint, Create a checkout session does not document properties on line_items[], but accepts it. Do not send a price — the server prices the line from the variant.
Step 3: Add properties to a line already in the cart
Use this when the shopper adds to cart through the theme's own button (so you never saw the request), or when the value is only decided after the line exists — an experiment group assigned on the home page, say, that must end up on every line the shopper adds later.
Listen for the storefront's dj.addToCart event, look the line up, merge, and write it back with Set the quantity of one line item.
document.addEventListener('dj.addToCart', function (e) {
var variantId = e.detail && e.detail.variant_id;
if (!variantId) return;
var root = window.SHOPLAZZA.routes.root;
// The event carries product_id / variant_id / quantity / properties but no line id,
// and PATCH needs the id — so fetch the cart to find the line.
fetch(root + '/api/cart', { headers: { 'Content-Type': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (data) {
var line = (data.cart.line_items || []).find(function (li) {
return li.variant_id === variantId;
});
if (!line) return;
// properties is a JSON string. A fresh line comes back as the string "[]",
// which parses to an ARRAY. Assigning keys onto an array and re-stringifying
// drops them, and the PATCH still returns 200. Collapse anything that is not a
// plain object to {} before merging.
var existing = {};
try {
var parsed = JSON.parse(line.properties || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) existing = parsed;
} catch (err) { /* keep {} */ }
if (existing._experiment_id) return; // already tagged
var merged = Object.assign({}, existing, { _experiment_id: 'demo_hero:A' });
return fetch(root + '/api/cart/' + variantId, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: line.id, // line item id — required
product_id: line.product_id,
variant_id: line.variant_id,
quantity: line.quantity, // send the current quantity back unchanged
properties: merged
})
});
});
});
Three ways this write fails silently, all returning HTTP 200:
- The
"[]"trap. A line with no properties hasproperties: "[]". Merging onto the parsed array loses every key on stringify. Always normalise to a plain object first, as above. - Properties are replaced, not merged, by the server. The
propertiesyou send becomes the whole set. Read the current value and merge before writing, or you will erase what another app (a product customiser, for instance) already stored on the line. - Omitting
quantitychanges the quantity. The endpoint is Set the quantity of one line item; send the line's currentquantityback or the line is resized.
Listen to dj.addToCart, not dj.cartChange. dj.cartChange fires on PATCH and DELETE as well, so a handler that patches the cart on dj.cartChange re-triggers itself indefinitely. Both events are described in Theme events.
Step 4: Hide a property from the shopper
A leading underscore in the key makes the property hidden. That is the whole mechanism, and it applies everywhere at once — cart page, checkout, the shopper's order detail, and the admin order detail. There is no per-surface control. Hidden properties are still returned by the API and by webhooks, which is what makes them useful for values only your systems need.
properties: {
'Engraving': 'Happy Birthday', // visible: shown wherever the line is displayed
'_design_id': 'dsg_8f21c' // hidden: returned by the API, never displayed
}
The visible key is the label the shopper reads, so write it in the language your store sells in. Themes render one row per key, so give each value its own key rather than concatenating several into one string.
On the admin order detail, the visible key appears under the line between the variant title and the SKU, and _design_id is nowhere on the page:
Custom Wall Mural
Engraving:Happy Birthday
SKU: MURAL-AREA-0.1M2
Step 5: Read properties back from the order
On the order, the field is custom_properties and it is already an object — see Get order.
{
"order": {
"number": "JMW07973",
"line_items": [
{
"quantity": 63,
"custom_properties": {
"Width": "205 cm",
"Height": "305 cm",
"Area": "6.25 m²",
"_billing_unit_area": "0.1",
"_billing_units": "63"
}
}
]
}
}
The same object is present on line_items[] in the orders/create webhook payload, so a backend can react to a new order without polling — see Listen for order events.
You write properties; you read custom_properties. Reading line_items[].properties on an order returns nothing — no error, no value.
Step 6 (optional): Find orders by a property
Do this step only if the merchant needs to filter the admin order list by one of your property values — pulling up every order from one experiment group, say, or every order carrying a particular design id. If reading the value off each order (Step 5) is enough, skip this step.
The reason it takes extra work: properties are not searchable. The order list's fuzzy-search fields cover the order number, customer name, SKU, tags, and about twenty more — custom_properties is not among them, and the admin's search-type dropdown has no such option either.
The way around it is to copy the value into an order tag from your backend when the order is created:
// In your orders/create webhook handler.
// Tags are replaced, not merged — read the current ones and add to them.
const value = order.line_items
.map(li => li.custom_properties && li.custom_properties._experiment_id)
.find(Boolean);
if (!value) return;
const current = (order.tags || '').split(',').map(t => t.trim()).filter(Boolean);
const tag = 'exp:' + value;
if (current.includes(tag)) return;
await fetch(`https://${shop}/openapi/2026-01/orders/${order.id}`, {
method: 'PUT',
headers: { 'Access-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ order: { tags: [...current, tag] } }) // send an array
});
Full fields: see Update order. Note the asymmetry — tags are sent as an array and read back as a comma-separated string.
Once tagged, the order is findable through both the API and the admin:
- API: List orders with
?order_tags=exp:demo_hero:Afor an exact match, or?fuzzy_fields=tag_list&fuzzy_keywords=exp:for a prefix. - Admin: order list → search type Order tags → enter the tag. The tag column can be enabled from Edit columns.
Verification
- Write — add a line with one plain key and one underscore key. Get the cart should return both inside the
propertiesstring. - Cart page — the plain key is listed under the product on its own row; the underscore key is absent.
- Buy now — the same two keys on Create a checkout session land on the checkout summary in the same way.
- Patch — add a line through the theme's own button, let your
dj.addToCarthandler run, then get the cart again. The merged key must be present and the line'squantityunchanged. Try it on a fresh line (properties"[]") specifically. - Order — complete the order. Get order returns both keys under
custom_properties; the admin order detail shows only the plain one. - Filter — after your webhook tags the order,
?order_tags=<tag>returns it, and searching the tag under Order tags in the admin finds it.
Worked example
Sell products priced by measurement puts all of this to work: a shopper enters width and height, the block writes Width / Height / Area as visible keys and the billing unit as hidden ones, and the order carries them to the workshop. It also shows what to do when the shopper's input has to change the price — which properties on their own cannot.