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)
markup — Sell price from your private cost: markup on cost or target margin (Pro/Enterprise)
tier — Volume, graduated or package quantity pricing (Pro/Enterprise)
discount — Percent or amount off, with conditions and stacking rules (Pro/Enterprise)
charge — Setup, labor, installation or surcharge, once or per unit (Pro/Enterprise)
freight — Flat, destination zones, weight bands, pickup or quoted separately (Pro/Enterprise)
tax — VAT / sales tax / GST at your rates, authorized exemptions (Pro/Enterprise)

How a price is calculated

The formula (or the sum of the item blocks) gives the unit price — markup and per-unit tier blocks are item blocks. Then, in this order: × order quantity (projectSettings.pricingOrderQuantity, default 1) → discounts → charges → freight → tax. Discount, charge, freight, tax and order-level tier blocks are applied in their own steps and are never formula terms. Amounts use exact decimal arithmetic and are rounded per line to the project’s decimal places.

The six CPQ types need a Pro or Enterprise workspace to create or update (403 otherwise); each accepts only its documented fields (400 naming any other). Option-keyed maps such as costPrices and freightWeights use "optionBlockId::optionValue" keys. Costs, markup rates and authorized tax IDs are returned to the owner only — the public Share payload carries each markup block as sellAmount / sellPrices.

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)
    }
  })
});

Create CPQ Blocks

Same endpoint, Pro and Enterprise. Tax rates are never preset — taxRate is required for a tax block to price.

const post = (body) => fetch(BASE_URL + '/projects/proj_123/pricing-blocks', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
  body: JSON.stringify(body)
});

// Volume pricing on the order quantity; the tier price replaces the Base Price
await post({
  type: 'tier', name: 'Volume pricing',
  tierMode: 'volume', tierApplication: 'unit',
  tierQuantitySource: { kind: 'order' },
  tiers: [{ upTo: 9, price: 20 }, { upTo: 49, price: 15 }, { upTo: null, price: 12 }],
  tierReplacesBlockIds: ['pb_base']
});

// A one-time setup fee (never multiplied by the quantity)
await post({ type: 'charge', name: 'Setup fee', chargeKind: 'setup', chargeCalc: 'fixed', chargeAmount: 49, chargeFrequency: 'once' });

// 10% off orders of 50 units or more
await post({
  type: 'discount', name: 'Trade discount', discountType: 'percent', discountValue: 10,
  conditions: [{ kind: 'quantity', op: 'gte', value: 50 }]
});

// Shipping the shopper chooses, and VAT
await post({ type: 'freight', name: 'Delivery', freightMethod: 'zones',
  freightZones: [{ id: 'dom', name: 'Domestic', amount: 15 }, { id: 'eu', name: 'EU', amount: 35 }] });
await post({ type: 'tax', name: 'VAT', taxKind: 'vat', taxRate: 20, taxMode: 'exclusive' });

// Read the order quantity from a number input
await fetch(BASE_URL + '/projects/proj_123', {
  method: 'PUT',
  headers: { 'Authorization': 'Bearer ' + accessToken, 'Content-Type': 'application/json' },
  body: JSON.stringify({ projectSettings: { pricingOrderQuantity: { kind: 'number-input', optionBlockId: 'blk_qty' } } })
});

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 }
// }

The staged result: cpq

Every response carries cpq — the itemized, staged price. Pass freightChoice to price a shipping option and taxExemptionId for a shopper’s tax ID (tax is removed only for an ID on a tax block’s authorized list). totalPrice, subtotal, tax and totalWithTax follow these totals.

body: JSON.stringify({
  selections: { 'blk_wood': 'walnut' },
  variables: { units: 60 },
  freightChoice: { blockId: 'pb_delivery', zoneId: 'eu' },
  taxExemptionId: 'DE123456789'
})

// response.cpq
// {
//   "status": "final",            // "final" | "estimate" | "pending" | "quote-required"
//   "reasons": [],                // e.g. { "code": "choose-delivery", "message": "Choose a delivery option to see shipping." }
//   "orderQuantity": 60,
//   "lines": [
//     { "kind": "unit-total", "label": "Configured price", "quantity": 60, "unitAmount": 12, "amount": 720 },
//     { "kind": "discount", "label": "Trade discount", "amount": -72 },
//     { "kind": "charge", "label": "Setup fee", "note": "one-time", "amount": 49 },
//     { "kind": "freight", "label": "Delivery — EU", "amount": 35 },
//     { "kind": "tax", "label": "VAT", "note": "20%", "amount": 146.4 }
//   ],
//   "totals": { "unitPrice": 12, "merchandise": 720, "discounts": 72, "charges": 49,
//               "freight": 35, "tax": 146.4, "total": 878.4, "net": 732 },
//   "freightOptions": [ { "key": "pb_delivery:dom", "blockId": "pb_delivery", "zoneId": "dom", "label": "Domestic", "amount": 15, "method": "zones" }, ... ],
//   "checkout": { "entered": 697, "net": 697, "gross": 836.4 }
// }
  • • Quotes and invoices: use cpq.totals.total and cpq.lines.
  • • Store checkout: charge cpq.checkout.entered — merchandise after discounts plus charges, without shipping and added tax, which the store finalizes. Simplio3D’s own Shopify, WooCommerce and WordPress integrations do exactly this.
  • • Never an unexplained zero: a missing quantity, an unchosen shipping option, a quantity no tier covers or a missing tax rate gives status: "pending" with a reason.

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