> ## Documentation Index
> Fetch the complete documentation index at: https://developers.appstle.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a fixed-price Build-a-Box with a custom UI

> Add a fixed-price Build-a-Box to the cart from your own storefront code, then save a customer's changed box contents on their existing subscription.

A fixed-price Build-a-Box sells one parent product at one price, and the customer chooses what goes inside it, such as the scents, flavors or blends. Appstle's own box page does all of this for you. This guide shows how to do the same from your own UI: add the box to the cart, then let the customer change its contents later without creating a second subscription.

> [!NOTE]
>
> Steps 1, 2 and 4 run in the customer's browser. Steps 1 and 4 call Appstle's **Storefront API** through Shopify's App Proxy, so they need no API key; it isn't Shopify's Storefront GraphQL API. Step 3 and [Save from your server instead](#save-from-your-server-instead) use the **Admin API** from your server, with an API key. If the store changed its App Proxy path, replace `/apps/subscriptions` with it. Read [Build your own frontend](https://developers.appstle.com/subscription/build-your-own-frontend.md) first if you haven't used the App Proxy before.

## What you'll build

* A cart add that puts a fixed-price box in the cart with a selling plan, and carries the customer's choices as line item properties.
* An account-page call that saves a changed selection on the customer's existing subscription line.

## Before you start

* A fixed-price box (a **Fixed Pricing Bundle**), enabled in the **Build-A-Box** section of the Appstle Subscriptions app. Note its reference: the box list shows it as **Ref**, and the API calls it `uniqueRef`.
* A selling plan attached to the box's parent product.
* A signed-in customer for Step 4. Steps 1 and 2 also work for guests.

## How a fixed-price box is stored

A fixed-price box is one ordinary line: the box's **parent product** with a selling plan. The customer's choices travel on that line as five line item properties, and they stay on the subscription line after checkout. That is why you can change the choices later by updating that line's properties, without adding a line or a subscription.

| Property | Value | Notes |
| - | - | - |
| `products` | `1x Colombia Coffee, 2x Decaf Coffee` | Quantity and title of each choice, joined with `, `. This is the only property the customer sees at checkout. |
| `_appstle-bb-id` | `kz3f9d2x` | The box's `uniqueRef`. It identifies the box when you save a change later. |
| `__appstle-bb-name` | `Roasters Choice Box` | The box's name. |
| `_appstle-bb-product-sku` | `COL-250,DEC-250` | The SKU of each chosen variant, comma-separated, with an empty entry for a variant that has no SKU. |
| `__appstle-bb-variants` | `44190100000002:1,44190100000004:2` | Each chosen variant as `variantId:quantity`, comma-separated. Use numeric Shopify variant IDs. |

Shopify doesn't show properties whose names start with an underscore to the customer at checkout, so only `products` appears on the order summary.

## Step 1: Read the box

Get the box's rules and its choices. The call goes through the App Proxy, so it needs no API key and works for guests as well as signed-in customers:

```javascript theme={null}
const ref = 'kz3f9d2x'; // the box's uniqueRef
const parentProductId = 15386745176368; // the parent product you sell
const response = await fetch(
  `/apps/subscriptions/bb/api/v3/subscription-bundlings/external/get-bundle/${ref}`
);
if (response.status !== 200) throw new Error(`Box ${ref} is unavailable (${response.status})`);
const { bundle } = await response.json();
const settings = JSON.parse(bundle.singleProductSettings)
  .find((entry) => entry.sourceProduct.id === parentProductId);
```

The [reference](https://developers.appstle.com/subscription-storefront-api/build-a-box-&-bundles/get-public-build-a-box-bundle-details-by-handle-v3.md) lists the same call under `/apps/subscriptions/cp/api/`, which needs a signed-in customer. The call answers `204` with an empty body when no box has this reference or the box is disabled.

`bundle.singleProductSettings` is a JSON string. After parsing, it holds one entry per parent product, so a box with several parents has several entries. Use the entry whose `sourceProduct` is the parent you sell:

```json theme={null}
[
  {
    "sourceProduct": { "id": 15386745176368, "title": "Ethiopia Coffee", "handle": "ethiopia-coffee", "price": 22 },
    "minQuantity": 1,
    "maxQuantity": "4",
    "products": [
      { "id": 15386745078064, "type": "PRODUCT", "title": "Colombia Coffee", "productHandle": "colombia-coffee" },
      { "id": 15386744946992, "type": "PRODUCT", "title": "Dark Roast Coffee", "productHandle": "dark-roast-coffee" },
      { "id": 15386745340208, "type": "PRODUCT", "title": "Decaf Coffee", "productHandle": "decaf-coffee" }
    ]
  }
]
```

| Field | Meaning |
| - | - |
| `sourceProduct` | The parent product you add to the cart. |
| `minQuantity`, `maxQuantity` | The fewest and most items a customer can pick in total. Either can be a number or a string, so convert it with `Number()`. When one is missing, the save doesn't enforce that limit. |
| `products` | The choices the customer can pick from. |
| `products[].type` | `PRODUCT`: the customer picks a variant of product `id`. `VARIANT`: `id` is the one variant the customer can pick, and `productId` is its product. |

Read the rest from Shopify's product JSON (`/products/<handle>.js`). The parent product's `selling_plan_groups[].selling_plans[].id` is the selling plan. For a `PRODUCT` choice, its `variants[].id` and `variants[].sku` go into `__appstle-bb-variants` and `_appstle-bb-product-sku`. For a `VARIANT` choice, read the product at its `productHandle` and use the variant whose `id` matches. Offer only these choices: Appstle's box page refuses anything else, and your UI must too.

> [!NOTE]
>
> The same box is available to your server as `GET /api/external/v2/build-a-box/{handle}` with an API key, where `{handle}` is the `uniqueRef`. It answers `404` while the box is disabled. See [Retrieve Build-A-Box bundle details by handle](https://developers.appstle.com/subscription-admin-api/build-a-box-&-bundles/retrieve-build-a-box-bundle-details-by-handle-for-customer-storefront.md).

## Step 2: Add the box to the cart

Add the parent variant with the selling plan and the five properties. This is Shopify's Cart API, so no Appstle call is involved:

```javascript theme={null}
async function addBoxToCart({ parentVariantId, sellingPlanId, box, picks }) {
  // picks: [{ variantId, title, sku, quantity }, ...]
  const response = await fetch('/cart/add.js', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      items: [{
        id: parentVariantId,
        quantity: 1,
        selling_plan: sellingPlanId,
        properties: {
          products: picks.map((p) => `${p.quantity}x ${p.title}`).join(', '),
          '_appstle-bb-id': box.uniqueRef,
          '__appstle-bb-name': box.name,
          '_appstle-bb-product-sku': picks.map((p) => p.sku || '').join(','),
          '__appstle-bb-variants': picks.map((p) => `${p.variantId}:${p.quantity}`).join(','),
        },
      }],
    }),
  });
  if (!response.ok) throw new Error(`Cart add failed: ${response.status}`);
  return response.json();
}
```

Keep the total of the picks between `minQuantity` and `maxQuantity`. Shopify's cart add doesn't check it, but the save in Step 4 does.

> [!WARNING]
>
> Shopify answers `422` with `Cannot apply selling plan to variant` when the selling plan isn't attached to the parent variant. Take the plan ID from the parent product's `selling_plan_groups`.

### Cart clearing

The **Cart Clearing Behavior** setting in the store's Build-A-Box settings applies only to Appstle's own box page. Your `/cart/add.js` call leaves the cart as it is. To start the customer with an empty cart, call Shopify's `POST /cart/clear.js` before you add the box.

## Step 3: Check the subscription after checkout

When the customer pays, Shopify creates the subscription, and the five properties are copied onto its line unchanged. Read the contract with the Admin API on your server to confirm:

```bash theme={null}
curl "https://subscription-admin.appstle.com/api/external/v2/subscription-contracts/contract-external/5001234567" \
  -H "X-API-Key: YOUR_API_KEY"
```

```json theme={null}
{
  "id": "gid://shopify/SubscriptionContract/5001234567",
  "lines": {
    "nodes": [
      {
        "id": "gid://shopify/SubscriptionLine/2f8c1d4e-7a3b-4c19-9d5e-6b0a1c2d3e4f",
        "title": "Ethiopia Coffee",
        "quantity": 1,
        "customAttributes": [
          { "key": "products", "value": "1x Colombia Coffee, 2x Decaf Coffee" },
          { "key": "_appstle-bb-id", "value": "kz3f9d2x" },
          { "key": "__appstle-bb-name", "value": "Roasters Choice Box" },
          { "key": "__appstle-bb-variants", "value": "44190100000002:1,44190100000004:2" },
          { "key": "_appstle-bb-product-sku", "value": "COL-250,DEC-250" }
        ]
      }
    ]
  }
}
```

On the storefront, the signed-in customer reads the same data from `GET /apps/subscriptions/cp/api/subscription-customers-detail/valid/{customerId}` ([reference](https://developers.appstle.com/subscription-storefront-api/subscription-contracts/get-valid-subscription-contracts-for-a-customer.md)), where `{customerId}` is the customer's numeric Shopify ID (`{{ customer.id }}` in Liquid). Each contract has a `subscriptionContractId`, and its `contractDetailsJSON` is a JSON string with one entry per line. Each entry has a `lineId` and the line's `customAttributes`. The box line is the one whose `customAttributes` include `_appstle-bb-id`.

## Step 4: Save a changed selection

To change what is in the box, send the new properties for the existing line. Don't add a second line or a new subscription:

```javascript theme={null}
async function saveBoxSelection({ contractId, lineId, box, picks }) {
  const attributes = [
    { key: 'products', value: picks.map((p) => `${p.quantity}x ${p.title}`).join(', ') },
    { key: '_appstle-bb-id', value: box.uniqueRef },
    { key: '__appstle-bb-name', value: box.name },
    { key: '_appstle-bb-product-sku', value: picks.map((p) => p.sku || '').join(',') },
    { key: '__appstle-bb-variants', value: picks.map((p) => `${p.variantId}:${p.quantity}`).join(',') },
  ];
  const query = new URLSearchParams({ contractId, lineId });
  const response = await fetch(
    `/apps/subscriptions/cp/api/subscription-contracts-edit-line-item-attributes?${query}`,
    {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(attributes),
    }
  );
  if (!response.ok) {
    const body = await response.json().catch(() => ({}));
    // 400: body.title, such as "UserGeneratedError: Total bundle quantity (5) ... exceeds the maximum allowed (4)"
    throw new Error(body.title || body.error || `Save failed: ${response.status}`);
  }
  return response.json();
}
```

| Parameter | In | Type | Required | Description |
| - | - | - | - | - |
| `contractId` | query | integer | Yes | The `subscriptionContractId` from Step 3. |
| `lineId` | query | string | Yes | The box line's `lineId`, a `gid://shopify/SubscriptionLine/...` value. It stays the same after the save. |
| Body | JSON | array | Yes | `{ "key": string, "value": string }` pairs: all five box properties, plus every other property already on the line. |

On success the call returns `200` with the updated contract, in the same shape as the Step 3 response, and the line carries the new properties. The line ID doesn't change and the customer still has one subscription. See [Edit custom attributes on a single line item](https://developers.appstle.com/subscription-storefront-api/subscription-contracts/edit-custom-attributes-on-a-single-line-item.md).

### What the save checks

The app runs these checks before it saves a customer's change. A failed check answers `400` with `application/problem+json`, and its `title` holds the message. The time-window and frozen-subscription messages are written for the customer: show them without the `UserGeneratedError:` prefix. The others point to a problem in your request or the customer's picks, so show your own text for them, such as "Choose between 1 and 4 items."

| Check | `title` (example) |
| - | - |
| The contract belongs to the signed-in customer | `Contract does not exist` |
| The body has `_appstle-bb-id` | `UserGeneratedError:bbId not found in attributeInfos` |
| The box exists in this store | `UserGeneratedError:No SubscriptionBundling found for bbId: <id> in shop: <shop>` |
| The body has `__appstle-bb-variants` | `UserGeneratedError:__appstle-bb-variants key not found in attributes` |
| The total is at least the box's minimum | `UserGeneratedError: Total bundle quantity (0) for bbId (kz3f9d2x) is less than the minimum allowed (1)` |
| The total is at most the box's maximum | `UserGeneratedError: Total bundle quantity (5) for bbId (kz3f9d2x) exceeds the maximum allowed (4)` |
| The store's product-change window allows it | `UserGeneratedError:Product modifications cannot be made within 3 days of your next billing date` or `...can only be made within 7 days of your next billing date` |
| The subscription isn't frozen until its minimum number of cycles, unless the store allows swaps while frozen | The store's own message |
| The line already has `_appstle-bb-id` | `UserGeneratedError:This line is not a Build-A-Box product.` |
| The `_appstle-bb-id` in the body matches the line's | `UserGeneratedError:Build-A-Box identity cannot be changed.` |
| Each property that isn't Appstle's appears once | `UserGeneratedError:Duplicate custom attribute key.` |
| Properties that aren't Appstle's are unchanged, or the store lets customers edit them | `UserGeneratedError:Permission denied, Editing attributes is not allowed` |

A customer can change only `products`, `__appstle-bb-variants` and `_appstle-bb-product-sku`. Every other Appstle property keeps its old value, whether you send it or not. Properties that aren't Appstle's, such as a gift message from another app, work differently: the line keeps exactly the ones you send. Send each of them back unchanged. Adding, changing or leaving one out needs the store's **Allow customers to edit attribute values** setting, and a property you leave out is then removed.

A call without a signed-in customer answers `401`. Storefront calls are rate limited per customer: a call over the limit answers `429` with a `Retry-After` header, so wait that many seconds before you retry. Save once, when the customer confirms the new box, not on every change.

> [!NOTE]
>
> The time-window, frozen-subscription and edit-permission checks depend on the store's customer portal settings. The other checks apply to every store.

## Save from your server instead

The Admin API has an equivalent call for your own back office. It uses an API key and acts as the merchant, so none of the customer checks above run, including the box's minimum and maximum. It replaces the line's properties with exactly the list you send: send all five box properties and every other property the line should keep.

```bash theme={null}
curl -X PUT "https://subscription-admin.appstle.com/api/external/v2/subscription-contracts-update-line-item-attributes?contractId=5001234567&lineId=gid%3A%2F%2Fshopify%2FSubscriptionLine%2F2f8c1d4e-7a3b-4c19-9d5e-6b0a1c2d3e4f" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    { "key": "products", "value": "1x Colombia Coffee, 2x Decaf Coffee" },
    { "key": "_appstle-bb-id", "value": "kz3f9d2x" },
    { "key": "__appstle-bb-name", "value": "Roasters Choice Box" },
    { "key": "_appstle-bb-product-sku", "value": "COL-250,DEC-250" },
    { "key": "__appstle-bb-variants", "value": "44190100000002:1,44190100000004:2" }
  ]'
```

See [Update line item attributes for a subscription contract](https://developers.appstle.com/subscription-admin-api/subscription-contracts/update-line-item-attributes-for-a-subscription-contract.md).

## Next steps

> [card] [Build your own frontend](https://developers.appstle.com/subscription/build-your-own-frontend.md)
>
> Selling plans, the cart and the customer portal from your own UI.

> [card] [JavaScript hooks](https://developers.appstle.com/subscription/javascript-hooks.md)
>
> Events the widget fires that your code can listen to.

> [card] [Authentication](https://developers.appstle.com/subscription/authentication.md)
>
> Admin API keys and how storefront calls are authenticated.

> [card] [Integration guide](https://developers.appstle.com/subscription/integration-guide.md)
>
> Server-side calls for subscriptions and contracts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.