Pricing & CPQ
Configure and calculate prices with base prices, lookup tables, variables, and formulas.
List Pricing Blocks
const blocks = await client.getPricingBlocks('proj_123');
blocks.forEach(b => {
console.log(b.name, b.type, b.basePrice || b.variableKey);
});Create Base Price
const block = await client.createPricingBlock('proj_123', {
type: 'base-price',
name: 'Base Price',
basePrice: 499.99,
baseCurrency: 'USD'
});Create Variable
const widthVar = await client.createPricingBlock('proj_123', {
type: 'variable',
name: 'Width',
variableKey: 'width',
variableDefaultValue: 120,
variableMin: 80,
variableMax: 200,
variableStep: 10,
variableUnit: 'cm'
});Update Price Table
await client.updatePricingBlock('proj_123', 'pb_3', {
tableRows: [
{ id: 'r1', cells: { col_1: 'oak' }, priceAdjustment: 0 },
{ id: 'r2', cells: { col_1: 'walnut' }, priceAdjustment: 120 },
{ id: 'r3', cells: { col_1: 'marble' }, priceAdjustment: 399 }
]
});Calculate Price
// calculatePrice takes a FLAT selections map: { blockId: variantValue }.
// It is the ONLY endpoint with this shape — evaluateConditions takes the
// grouped form. checkboxSelections and modularSelections are TOP-LEVEL
// siblings of selections, never nested inside it.
const price = await client.calculatePrice('proj_123', {
selections: {
'blk_wood': 'walnut',
'blk_armrests': 'with'
},
checkboxSelections: { 'blk_accessories': ['cup-holder'] },
modularSelections: {}, // blockId -> placed variant IDs (duplicates = quantity)
variables: { width: 160, quantity: 2 }
});
console.log('Base:', price.basePrice);
console.log('Adjustments:', price.adjustments);
// TAX: charge totalWithTax and display formatted — mixing them shows one
// number and charges another. subtotal is pre-tax in BOTH modes; totalPrice is
// NOT (it equals subtotal in exclusive mode but totalWithTax in inclusive),
// so avoid totalPrice on the money path.
console.log('Pre-tax subtotal:', price.subtotal, price.currency);
console.log('Charge:', price.totalWithTax);
console.log('Display:', price.formatted); // "$1,223.99" (incl. tax)
console.log('Tax:', price.formattedTax); // "$204.00"
console.log('Subtotal:', price.formattedSubtotal); // "$1,019.99"
console.log(price.taxEnabled, price.taxMode, price.taxRate, price.taxLabel);
// Confirm the selections actually matched pricing blocks:
console.log(price.matchedBlockIds); // [] + non-empty selections = nothing matched
console.log(price.unmatchedSelectionKeys); // keys that hit no pricing block
// Tax, formatting, and currency come from Project Settings > Pricing display
// What the server engine applied to THIS total (both true on the current
// runtime; absent on an older deployment, where you must assume false).
console.log(price.engineApplied?.numberInputPricing); // value x price per unit
console.log(price.engineApplied?.conditionalGating); // hidden blocks excluded
console.log(price.hiddenOptionBlockIds); // blocks a rule hid, so not pricedselections here is FLAT — and a wrong shape fails silently
calculatePrice is the one selection-carrying call that takes a flat { blockId: variantValue } map. Every other surface — notably evaluateConditions — takes the grouped shape (dropdownSelections, selectMaterialSelections, …). Historically, sending the grouped shape did not throw: it matched nothing and returned base prices, which is indistinguishable from a correctly-priced default configuration.
The server now detects a grouped map and flattens it before pricing, so callers written against the old type are repaired at runtime rather than failing silently. Because flattening happens first, the grouped wrapper keys are consumed and will not appear in the diagnostics below — a grouped payload now simply prices correctly. New code should still send the flat shape.
Two additive response fields report what actually matched: matchedBlockIds (block IDs that contributed to the total) and unmatchedSelectionKeys (keys that hit no pricing block). An empty matchedBlockIds alongside a non-empty selections map means nothing matched — the total is base/default, not a priced configuration. Keys land in unmatchedSelectionKeys when a block ID is stale, a variant value no longer exists, or no pricing block references that block.
checkboxSelections and modularSelections are top-level siblings of selections, not members of it.
Reading the price response: pre-tax vs post-tax
formatted is the total including tax, so displaying formatted while charging totalPrice can show the customer one number and bill another.
totalPrice is mode-dependent — do not treat it as “the pre-tax total”. With taxMode: 'exclusive' it equals subtotal (pre-tax) and totalWithTax adds tax on top. With taxMode: 'inclusive' it equals totalWithTax (tax already included) and subtotal is the smaller pre-tax figure. Only subtotal is pre-tax in both modes.
| Field | Tax | Use it for |
|---|---|---|
| totalPrice | MODE-DEPENDENT — pre-tax in exclusive, gross in inclusive | Nothing on the money path. Prefer subtotal or totalWithTax |
| totalWithTax | included (both modes) | The amount you actually charge |
| subtotal | excluded (both modes) | A cart line when your commerce platform applies tax itself |
| tax | tax only | An itemised order breakdown |
| formatted | included | Display — the post-tax total, currency-formatted |
| formattedSubtotal | excluded | Display — pre-tax subtotal |
| formattedTax | tax only | Display — the tax line, labelled with taxLabel |
Rule of thumb: charge totalWithTax, or pass subtotal when your commerce platform adds tax itself. Never pass totalPrice to a tax-applying platform — on an inclusive-mode project that taxes an already-taxed figure. Which number totalPrice equals depends on taxMode, a per-project setting the owner can change at any time, so branch on the returned taxMode rather than assuming one.
calculatePrice() now returns the response directly
The method previously looked for a price wrapper that the server has never sent, so it threw on every call. It now returns CalculatePriceResponse itself — the pricing fields are top-level (basePrice, adjustments, totalPrice, formatted, …). If you wrote a fetch fallback around the old failure, you can delete it; no response field moved.
