Pricing & CPQ
Configure pricing with base prices, price groups, lookup tables, unique modular prices, and variables. Build formulas that combine pricing blocks with mathematical operators. Calculate real-time prices based on user selections.
Pricing Block Types
List Pricing Blocks
const response = await fetch(BASE_URL + '/projects/proj_123/pricing-blocks', {
headers: { 'Authorization': 'Bearer ' + accessToken }
});
const data = await response.json();
// {
// "success": true,
// "blocks": [
// { "id": "pb_1", "type": "base-price", "name": "Base Price",
// "basePrice": 499.99, "baseCurrency": "USD", "enabled": true },
// { "id": "pb_2", "type": "variable", "name": "Width",
// "variableKey": "width", "variableDefaultValue": 120,
// "variableMin": 80, "variableMax": 200, "variableStep": 10, "variableUnit": "cm" },
// { "id": "pb_3", "type": "price-table", "name": "Material Upcharge",
// "tableColumns": [
// { "id": "col_1", "label": "Material", "type": "option-ref" },
// { "id": "col_2", "label": "Price", "type": "number" }
// ],
// "tableRows": [
// { "id": "row_1", "cells": { "col_1": "oak", "col_2": 0 }, "priceAdjustment": 0 },
// { "id": "row_2", "cells": { "col_1": "walnut", "col_2": 89 }, "priceAdjustment": 89 },
// { "id": "row_3", "cells": { "col_1": "marble", "col_2": 299 }, "priceAdjustment": 299 }
// ]
// },
// { "id": "pb_4", "type": "unique-price", "name": "Connect Full Wall Unique Price",
// "uniquePriceModularBlockId": "blk_roof_parts",
// "uniquePriceVariantId": "mod_connect_full_wall",
// "uniquePriceColumnOptionBlockId": "blk_treatment",
// "uniquePriceQuantities": [1, 2],
// "uniquePriceCellPrices": { "1::natural": 249, "2::natural": 699 },
// "uniquePriceDefaultCellPrices": { "natural": 399, "painted": 549 }
// }
// ]
// }Create Pricing Block
// Create a variable for "Width" dimension
const response = await fetch(BASE_URL + '/projects/proj_123/pricing-blocks', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'variable',
name: 'Width',
variableKey: 'width',
variableDefaultValue: 120,
variableMin: 80,
variableMax: 200,
variableStep: 10,
variableUnit: 'cm'
})
});Update Pricing Block
// Update a price table with new rows
const response = await fetch(BASE_URL + '/projects/proj_123/pricing-blocks/pb_3', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tableRows: [
{ id: 'row_1', cells: { col_1: 'oak', col_2: 0 }, priceAdjustment: 0 },
{ id: 'row_2', cells: { col_1: 'walnut', col_2: 120 }, priceAdjustment: 120 },
{ id: 'row_new', cells: { col_1: 'carbon-fiber', col_2: 450 }, priceAdjustment: 450 }
]
})
});Set Pricing Formula
The formula that combines pricing blocks is stored as sceneData.pricingFormula on the project, so it is saved with the project update rather than through a dedicated endpoint. It is a token list of block references, numbers, operators and parentheses. If the formula is empty or fails to parse, all enabled pricing blocks are summed instead.
// Read the project, replace the formula, write it back.
const { project } = await (await fetch(BASE_URL + '/projects/proj_123', {
headers: { 'Authorization': 'Bearer ' + accessToken }
})).json();
const response = await fetch(BASE_URL + '/projects/proj_123', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
sceneData: {
...project.sceneData,
pricingFormula: [
{ type: 'block', blockId: 'pb_1' }, // @Base_Price
{ type: 'operator', value: '+' },
{ type: 'block', blockId: 'pb_3' }, // @Material_Upcharge
{ type: 'operator', value: '+' },
{ type: 'paren', value: '(' },
{ type: 'block', blockId: 'pb_2' }, // @Width
{ type: 'operator', value: '*' },
{ type: 'number', value: '2.5' }, // per-cm cost
{ type: 'paren', value: ')' }
]
// Human readable: @Base_Price + @Material_Upcharge + (@Width * 2.5)
}
})
});Calculate Price
Calculate a price from a set of option-block selections and variable values.
This endpoint takes a flat selections map
selections here is { blockId: variantValue }. That is different from /option-blocks/evaluate, which takes the grouped form (dropdownSelections, selectMaterialSelections, …). Multi-select and modular values are separate top-level fields here.
Current servers detect the grouped form and flatten it before matching, so it prices correctly. Historically it did not: the grouped form matched nothing and returned base/default prices with HTTP 200, indistinguishable from a correctly-priced default. Flat remains the canonical shape — send it, and check matchedBlockIds (below) to confirm what the server matched.
const response = await fetch(BASE_URL + '/projects/proj_123/pricing-blocks/calculate', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
// FLAT: block id -> selected variant value
selections: {
'blk_wood': 'walnut',
'blk_armrests': 'with'
},
// Multi-select and modular values are separate, top-level fields
checkboxSelections: { 'blk_accessories': ['cup-holder'] },
modularSelections: { 'blk_modules': ['var_panel', 'var_panel', 'var_door'] },
variables: {
width: 160,
quantity: 2
}
})
});
const data = await response.json();
// {
// "success": true,
// "basePrice": 499.99,
// "adjustments": [
// { "blockId": "pb_3", "blockName": "Material Upcharge", "amount": 120 },
// { "blockId": "pb_2", "blockName": "Width", "amount": 400 }
// ],
// "totalPrice": 1019.99,
// "currency": "USD",
// "subtotal": 1019.99,
// "tax": 203.998,
// "totalWithTax": 1223.988,
// "taxRate": 20,
// "taxMode": "exclusive",
// "taxLabel": "VAT",
// "taxEnabled": true,
// "formatted": "$1,223.99",
// "formattedSubtotal": "$1,019.99",
// "formattedTax": "$204.00",
//
// // Match report — which selection keys the engine actually resolved.
// "matchedBlockIds": ["blk_wood", "blk_armrests", "blk_accessories", "blk_modules"],
// "unmatchedSelectionKeys": [],
//
// // Engine report — blocks conditional logic hid (excluded from the total),
// // and which option-block-dependent features this calculation applied.
// "hiddenOptionBlockIds": ["blk_indoor_only"],
// "engineApplied": { "numberInputPricing": true, "conditionalGating": true }
// }Confirming the request matched
A mis-shaped or stale-id selections map still returns 200 with a plausible base price, so the response carries a match report you can assert on:
- •
matchedBlockIds— the selection keys that resolved to a pricing-relevant block. - •
unmatchedSelectionKeys— the keys that did not. Usually a renamed or deleted block id, or a key from the wrong keyspace. - • An empty
matchedBlockIdsalongside a non-empty selections map means nothing matched — the total you got is the base/default price, not a priced configuration. Treat it as a failure in your integration tests.
const data = await response.json();
// Guard: a 200 does NOT mean your selections were understood.
if (Object.keys(selections).length > 0 && data.matchedBlockIds.length === 0) {
throw new Error('Pricing matched nothing — check the selections shape and block ids');
}
if (data.unmatchedSelectionKeys.length > 0) {
console.warn('Ignored selection keys:', data.unmatchedSelectionKeys);
}What this engine prices — and what it still does not
Totals changed in v3.7. The server engine now applies two things it previously skipped, so a project using either of them prices differently (and correctly) against the current runtime than it did before — re-baseline any snapshot test that asserts an exact total:
- • Number-input per-unit pricing. A Price Group, Price Table axis or Variable linked to a
number-inputblock is now multiplied by the runtime numeric value (value × price, summed across the block’s numeral variants). Previously those contributions were missing entirely. - • Conditional-visibility gating. Blocks hidden by conditional rules are excluded from the total and listed in
hiddenOptionBlockIds. Previously a hidden block still contributed.
engineApplied reports both as booleans rather than leaving you to assume them, because the difference is a different number, not a different flag. Both are true on the current runtime.
Still divergent from the shopper-facing runtime — this remains a simplified engine, so do not present it as the configurator’s own:
- • The bespoke
palmako-priceblock resolves to 0— its L-shape detection runs off placed-module geometry this endpoint never sees. - • Gating uses the shared server rule evaluator, which reads only
is-selected/is-not-selected. A block hidden by a numeric condition (greater-than,less-than,equals,between), by a modularsourceModularSidefilter, or by a modular-sourced condition is still priced here. - • No per-line breakdown and no SKU resolution.
adjustmentsis one entry per pricing block, not the shopper-facing breakdown rows.
Deployment skew
Grouped-shape tolerance, the two match-reporting fields, and hiddenOptionBlockIds / engineApplied are additive. An older deployment omits them — read them defensively (?? [], and treat a missing engineApplied as both false).
An older deployment also still requires the flat selections map, so always send the flat shape regardless of which runtime you are talking to.
totalPrice is pre-tax; formatted is post-tax
These two are not the same number whenever tax is enabled in exclusive mode. totalPrice equals subtotal (pre-tax), while formatted is the currency-formatted totalWithTax. Rendering formatted while charging totalPrice displays one number to the customer and bills another — in the example above that is a $204.00 discrepancy.
- • Charging / cart line: use
totalWithTax(orsubtotalwhen your checkout applies tax itself — pick one, never mix). - • Display: use
formatted,formattedSubtotalandformattedTax, which already honour the project’s currency, symbol position and decimal places. - •
taxEnabled,taxRate,taxModeandtaxLabeltell you which line items to show. WhentaxEnabledisfalse,totalPriceandtotalWithTaxare equal.
