Bundles

One product that shows several customizable products in one photo — a tank, shorts and socks worn together — each member with its own artwork and variant, rendered through the same URL grammar as any product plus one level: the member's role.

Applies to @snowcone-app/sdk@0.24.1

What a bundle is

A bundle is a catalog product like any other — its own code, slug and mockups — whose record carries a bundle block: a list of members. A member is a slot identified by a role (top, shorts) and lists one or more alternative products that can fill it; the first is the default. The photo shows one alternative per slot, and every member keeps its own placements, artwork and variant.

The bundle’s price rule (percent_off in basis points, flat_off_cents, fixed_price_cents — always cents) applies to the sum of the picked members’ prices when every required slot is filled.

Discover roles and placements

getProduct() and the MCP tool snowcone_get_product return the record. On a bundle every placement key is role-scoped top.front, shorts.front — and names the member product it belongs to. Two members can both have a placement labelled “Front”, so address placements by key, never by label. Each mockup says which alternative it depicts per slot (picks); pick sets no photo covers are listed in bundle.uncoveredPickSets.

json
// snowcone_get_product / getProduct("SUMSET") — the parts a bundle adds
{
  "id": "SUMSET",
  "kind": "bundle",
  "bundle": {
    "members": [
      { "role": "top",    "required": true,
        "products": [{ "productId": "TANKXA" }, { "productId": "TEEXAB" }] },
      { "role": "shorts", "required": true,
        "products": [{ "productId": "SHORTS" }] }
    ],
    "priceRule": { "kind": "percent_off", "bps": 1000 },
    "uncoveredPickSets": []
  },
  "placements": [
    { "key": "top.front",    "label": "Front", "role": "top",    "memberProductId": "TANKXA" },
    { "key": "top.back",     "label": "Back",  "role": "top",    "memberProductId": "TANKXA" },
    { "key": "top.front",    "label": "Front", "role": "top",    "memberProductId": "TEEXAB" },
    { "key": "shorts.front", "label": "Front", "role": "shorts", "memberProductId": "SHORTS" }
  ],
  "mockups": [
    { "id": "m1", "picks": { "top": "TANKXA", "shorts": "SHORTS" } }
  ]
}

Render by URL

The public URL grammar gains one middle level. Resolution is base → member → placement; the deepest present value wins, and default blanks at any level:

  • asset= — seeds every placement of every member.
  • asset.<role>= — seeds every placement of one member.
  • asset.<role>.<key>=, color.<role>.<key>= — one placement of one member.
  • pick.<role>=<productId> — which alternative fills the slot (default: the first).
  • variant.<role>=<gvid>, opt.<role>.<attribute>=<choice> — the SKU and render-only options per slot.
bash
# one artwork on every member (the base broadcasts, ADR-0089)
https://img.snowcone.app/SUMSET?shop=<shopId>&asset=https://cdn.example/art.png

# a different artwork per member
https://img.snowcone.app/SUMSET?shop=<shopId>
  &asset.top=https://cdn.example/jersey.png
  &asset.shorts=https://cdn.example/shorts.png

# one placement of one member (the deepest level wins)
https://img.snowcone.app/SUMSET?shop=<shopId>
  &asset=https://cdn.example/art.png
  &asset.top.back=default

# pick the tee instead of the tank, size the shorts, pick a render-only colour
https://img.snowcone.app/SUMSET?shop=<shopId>&asset=https://cdn.example/art.png
  &pick.top=TEEXAB
  &variant.shorts=<gvid>
  &opt.top.Color=Red
Picks resolve before artwork: they decide which placements exist and which mockup is chosen (the one whose picks cover the most slots). A bare variant= or opt.<attribute>= names no slot on a bundle and is refused.

First-party asset handles

Any asset value may be ingest:<sha256> — the handle of an artwork already ingested into Snowcone (the sha256 of its source URL). It is a fixed ~72 characters, so a bundle with many members stays far under the URL limits, and it needs no origin allowlisting.

bash
# a first-party asset handle in place of a long third-party URL
https://img.snowcone.app/SUMSET?shop=<shopId>
  &asset.top=ingest:9f2c…64 hex…
  &asset.shorts=ingest:41a0…64 hex…

Errors

Every wrong input is a typed 400 that lists the valid values: E_BUNDLE_ROLE_REQUIRED, E_BUNDLE_ROLE_NOT_FOUND, E_BUNDLE_PICK_NOT_FOUND, E_PLACEMENT_NOT_FOUND (listing the picked members’ keys), E_INGEST_HANDLE_INVALID, and E_NOT_A_BUNDLE for bundle parameters on a single product.

json
// variant= on a bundle names no slot
{ "error": "variant= names no slot on bundle SUMSET — use variant.<role>=<gvid>. Roles: top, shorts.",
  "code": "E_BUNDLE_ROLE_REQUIRED" }

// an alternative the slot does not list
{ "error": "Unknown pick \"HOODIE\" for role \"top\" on bundle SUMSET. Alternatives: TANKXA, TEEXAB.",
  "code": "E_BUNDLE_PICK_NOT_FOUND" }

// bundle params on a single product
{ "error": "pick.<role>= and variant.<role>= are bundle parameters — KMYKUK is not a bundle. Use variant=<gvid> and opt.<attribute>=<choice>.",
  "code": "E_NOT_A_BUNDLE" }

Realtime

A realtime session on a bundle takes picks and variants per slot in its config (never variantId), and its required placements are the role-scoped keys — one canvas_state per key. A pick change is a new config message.

tsx
// realtime config for a bundle: picks + variants per slot, never variantId
ws.send(JSON.stringify({
  type: "config",
  config: {
    productId: "SUMSET",
    mockupIds: ["m1"],
    shop: "<shopId>",
    picks:    { top: "TEEXAB" },          // default: each slot's first product
    variants: { shorts: "<gvid>" },        // default: the picked product's default
  },
}));
// config_received.requiredPlacements → ["top.front", "top.back", "shorts.front"]
// send one canvas_state per KEY: { type: "canvas_state", placement: "top.front", … }