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

base-price — Fixed starting price (one per project)
price-group — Logical folder for grouping pricing items
price-table — Lookup table mapping selections to price adjustments
unique-price — Enterprise Modular quantity lookup with per-column default fallback
variable — Named numeric parameter (e.g. width, quantity)

List Pricing Blocks

GET/projects/:id/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

POST/projects/:id/pricing-blocks
// 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

PUT/projects/:id/pricing-blocks/:blockId
// 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

PUT/projects/:id

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

POST/projects/:id/pricing-blocks/calculate

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 matchedBlockIds alongside 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-input block 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-price block 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 modular sourceModularSide filter, or by a modular-sourced condition is still priced here.
  • • No per-line breakdown and no SKU resolution. adjustments is 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 (or subtotal when your checkout applies tax itself — pick one, never mix).
  • Display: use formatted, formattedSubtotal and formattedTax, which already honour the project’s currency, symbol position and decimal places.
  • taxEnabled, taxRate, taxMode and taxLabel tell you which line items to show. When taxEnabled is false, totalPrice and totalWithTax are equal.

Continue reading