# Add custom properties to cart and orders

> Carry shopper-entered values such as engraving text from the product page into the Shoplazza cart with the Ajax API, then read them back off the order.

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

1. **Write.** Send `properties` as an object on [Add a variant to the cart](/docs/theme/references/ajax-api/cart#post-localeapicart) or in `line_items[]` on [Create a checkout session](/docs/theme/references/ajax-api/checkout#post-localeapicheckoutorder). Lines already in the cart can be updated with [Set the quantity of one line item](/docs/theme/references/ajax-api/cart#patch-localeapicartvariant_id).
2. **Carry.** The cart stores the properties on the line item. Checkout copies them onto the order line.
3. **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.
4. **Read back.** The order exposes them as `line_items[].custom_properties` — through [Get order](/api/order-detail) and on the `orders/create` webhook.

```mermaid
flowchart LR
    A["Add to cart<br/>POST /api/cart"] --> C["Cart line item<br/>properties"]
    P["Patch a line<br/>PATCH /api/cart/:variant_id"] --> C
    B["Buy now<br/>POST /api/checkout/order"] --> D[Checkout session]
    C --> D
    D --> E["Order line<br/>custom_properties"]
    E --> F[Order API]
    E --> G["orders/create webhook"]
```

## 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](/docs/app/use-cases/product-merchandising/sell-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](/docs/app/extensions/online-store/add-theme-extension).
- Familiarity with the [Cart Ajax API](/docs/theme/references/ajax-api/cart). All storefront calls below use `window.SHOPLAZZA.routes.root` to 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](/docs/app/getting-started/create-public-app/develop-app) — with the `order` access 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.

```javascript
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](/docs/theme/references/ajax-api/cart#get-localeapicart) shows how the line was stored:

```json
{
  "cart": {
    "line_items": [
      {
        "variant_id": "36e7ca49-343f-4ac2-a2d8-8ef2d8f7a105",
        "quantity": "1",
        "properties": "{\"Engraving\":\"Happy Birthday\",\"_design_id\":\"dsg_8f21c\"}"
      }
    ]
  }
}
```

:::note
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](/docs/theme/references/ajax-api/cart#post-localeapicart) 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.

```javascript
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):

```json
{
  "state": "success",
  "data": {
    "order_token": "2446407211694842140211",
    "checkout_url": "/checkout/2446407211694842140211"
  }
}
```

Like the cart endpoint, [Create a checkout session](/docs/theme/references/ajax-api/checkout#post-localeapicheckoutorder) 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](/docs/theme/references/ajax-api/cart#patch-localeapicartvariant_id).

```javascript
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
        })
      });
    });
});
```

:::warning
Three ways this write fails silently, all returning HTTP 200:

- **The `"[]"` trap.** A line with no properties has `properties: "[]"`. 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 `properties` you 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 `quantity` changes the quantity.** The endpoint is [Set the quantity of one line item](/docs/theme/references/ajax-api/cart#patch-localeapicartvariant_id); send the line's current `quantity` back or the line is resized.
:::

:::note
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](/docs/theme/theme-features/tracking-and-analytics/theme-events).
:::

## Step 4: Hide a property from the shopper {#hide-from-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.

```javascript
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](/api/order-detail).

```json
{
  "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](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events).

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

```javascript
// 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](/api/order-update). 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](/api/orders) with `?order_tags=exp:demo_hero:A` for 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

1. **Write** — add a line with one plain key and one underscore key. [Get the cart](/docs/theme/references/ajax-api/cart#get-localeapicart) should return both inside the `properties` string.
2. **Cart page** — the plain key is listed under the product on its own row; the underscore key is absent.
3. **Buy now** — the same two keys on [Create a checkout session](/docs/theme/references/ajax-api/checkout#post-localeapicheckoutorder) land on the checkout summary in the same way.
4. **Patch** — add a line through the theme's own button, let your `dj.addToCart` handler run, then [get the cart](/docs/theme/references/ajax-api/cart#get-localeapicart) again. The merged key must be present and the line's `quantity` unchanged. Try it on a fresh line (properties `"[]"`) specifically.
5. **Order** — complete the order. [Get order](/api/order-detail) returns both keys under `custom_properties`; the admin order detail shows only the plain one.
6. **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](/docs/app/use-cases/product-merchandising/sell-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.

## Next steps

- [Cart Ajax API](/docs/theme/references/ajax-api/cart)
- [Checkout Ajax API](/docs/theme/references/ajax-api/checkout)
- [Theme events](/docs/theme/theme-features/tracking-and-analytics/theme-events)
- [Listen for order events](/docs/app/use-cases/orders-and-fulfillment/listen-for-order-events)
