Skip to main content
Every item write endpoint returns items, but none of them returns a proposal. To read the current state of a proposal’s items — or to discover the ids you need before writing anything — call get proposal and walk into its areas.

Where items live

Items are nested three levels down. A proposal has areas, an area has client-selectable options, and each option holds the items the client is quoted for if they pick it:
Two ids from this payload drive every item call: the option’s id is the ProposalAreaOptionId you target when adding, moving, or copying items, and each item’s id is the ProposalItemId in every per-item path. Neither is discoverable any other way, so an integration that edits items starts with a GET. GET /public/proposals/{ProposalId} also sets a Last-Modified header from the proposal’s lastModifiedDate, which is the cheapest way to poll for outside changes.
Webhooks fire at proposal level only — proposal.status_changed, proposal.build.status_update, and proposal.outline.status_update. Nothing fires when an item is added, repriced, or deleted, so there is no push signal for item edits made in the Portal.io UI. Re-fetch the proposal instead.

Identifying an item

Item type decides whether the item’s name comes back in model or in name, which is the most common surprise when mapping the model:
  • Part and CustomItembrand and model, name is null.
  • Laborname, with brand and model null.
  • Fee — the name you submitted comes back in model, not name.
description is a deprecated legacy field. Read shortDescription.

Quantities and totals

quantity is a decimal stored to 2 decimal places, so fractional values are normal — 5.5 hours of labor in the payload above. Zero is legal too, and does not remove the item; delete items does that. total.amount is the per-unit sellPrice multiplied by quantity, before tax. It is not a tax-inclusive figure and it does not include nested items unless the parent has combined pricing switched on with set combined pricing — which only takes effect on a top-level, non-recurring item that has children.
When total.isCombinedPrice is true, the children’s totals are already inside the parent’s amount, yet each child still reports its own total unchanged. Summing parent and children then double-counts. Add up either the top-level items of an option or its leaf items, never both.
Recurring-service items sit outside the option’s regular total: they are excluded from total on the area option and counted in totalRecurringService instead. Sum both if you need the full monthly-plus-one-off picture.

Percentage pricing is dynamic

Sell price and cost accept either an absolute amount or a percentage of a basis. Percentages are whole numbers — 15 means 15%, resolved to 2 decimal places — and the basis decides how stable the resulting price is: A percentage-priced item has no fixed sellPrice of its own: writing to any other item that its basis covers changes it. If your system stores prices, re-read the proposal after a batch of writes rather than trusting the values returned by the individual calls.

When cost is null

cost on an item is null when you do not have permission to see costs. A null cost means “you cannot see it”, not “this item has no cost” — the difference matters if you are calculating margin or deciding whether to write a cost. List item suppliers is the exception: it returns cost details regardless of that restriction. It is also where the supplier’s numeric id lives, since an item only carries supplier as a display name.

Order of items

Items within an option are returned in their display order, and that order is not settable through the public API. Newly added items and copies are appended to the end of the destination option. Moved items are appended too, and every item in a single move is given the same position, so their relative order afterwards is not guaranteed.