Bundles
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.
// 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.
# 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=Redpicks 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.
# 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.
// 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.
// 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", … }
