Update editable Quote builder content
Hourly authoring through v1 or MCP is not supported; create hourly lines in the quote builder or use a saved hourly template. Updates purpose-built builder content families while the Quote is editable: sections and lines, pricing adjustments, solar/system design, financing scenarios, payment schedule milestones, incentives, assumptions, savings, and environmental data. Each supplied array replaces that entire content family; omit a family to leave it unchanged. Each financingScenarios entry must be one of the Quote's offeredFundingSources (see GET): send its financingOptionId with the same type. The saved option's terms are copied and any term you send overrides them. A loan's aprInput must be within the lender's saved APR range and its downPaymentInput at least the lender's minimum, and exactly one entry must have isDefault true: the funding source on the proposal (400 VALIDATION_FAILED). Changed 2026-10: custom terms (an entry with no financingOptionId) and options the Quote does not offer used to be stored and are now refused with 400 FUNDING_SOURCE_NOT_OFFERED; error.details.issues names each entry and why, and nothing is written. To migrate, sell the customer's own loan as Cash, or save the lender as a financing option in solarOS and turn it on for the Quote family: Quotes created after that offer it; GET offeredFundingSources shows whether an existing Quote does (a Quote created in solarOS keeps the list it was created with). Plain Cash with no financingOptionId is accepted only when the organization has no saved Cash option. To keep financing the Quote no longer offers, omit financingScenarios. Replacing sections that contain a managed travel charge returns 409. Frozen travel pricing cannot be edited; use the quote travel controls or duplicate the quote. Battery backupPercent and estimatedBackupHours are compatibility inputs that are not persisted; responses derive them from dispatch configuration and load assumptions. No-op PATCH requests return changed=false without ActivityLog, domain event, or timestamp churn. Material writes reject customer-facing Quote states, validate builder constraints such as solar-only system design, and emit ActivityLog plus quote.content.updated domain events with changedFields for webhook consumers. An accepted Quote returns 409 QUOTE_DESIGN_LOCKED: its design, usage, pricing and calculation are what was sold, and changes after the sale go through a change order on the project. Changed 2026-10: accepted Quotes returned 409 CONFLICT before; other customer-facing states still do. Required scope: quotes:content:write.
Hourly authoring through v1 or MCP is not supported; create hourly lines in the quote builder or use a saved hourly template. Updates purpose-built builder content families while the Quote is editable: sections and lines, pricing adjustments, solar/system design, financing scenarios, payment schedule milestones, incentives, assumptions, savings, and environmental data. Each supplied array replaces that entire content family; omit a family to leave it unchanged. Each financingScenarios entry must be one of the Quote's offeredFundingSources (see GET): send its financingOptionId with the same type. The saved option's terms are copied and any term you send overrides them. A loan's aprInput must be within the lender's saved APR range and its downPaymentInput at least the lender's minimum, and exactly one entry must have isDefault true: the funding source on the proposal (400 VALIDATION_FAILED). Changed 2026-10: custom terms (an entry with no financingOptionId) and options the Quote does not offer used to be stored and are now refused with 400 FUNDING_SOURCE_NOT_OFFERED; error.details.issues names each entry and why, and nothing is written. To migrate, sell the customer's own loan as Cash, or save the lender as a financing option in solarOS and turn it on for the Quote family: Quotes created after that offer it; GET offeredFundingSources shows whether an existing Quote does (a Quote created in solarOS keeps the list it was created with). Plain Cash with no financingOptionId is accepted only when the organization has no saved Cash option. To keep financing the Quote no longer offers, omit financingScenarios. Replacing sections that contain a managed travel charge returns 409. Frozen travel pricing cannot be edited; use the quote travel controls or duplicate the quote. Battery backupPercent and estimatedBackupHours are compatibility inputs that are not persisted; responses derive them from dispatch configuration and load assumptions. No-op PATCH requests return changed=false without ActivityLog, domain event, or timestamp churn. Material writes reject customer-facing Quote states, validate builder constraints such as solar-only system design, and emit ActivityLog plus quote.content.updated domain events with changedFields for webhook consumers. An accepted Quote returns 409 QUOTE_DESIGN_LOCKED: its design, usage, pricing and calculation are what was sold, and changes after the sale go through a change order on the project. Changed 2026-10: accepted Quotes returned 409 CONFLICT before; other customer-facing states still do. Required scope: quotes:content:write.
Update editable Quote builder content. Use an organization API key with quotes:content:write. Check the response envelope and meta.requestId before triggering dependent workflow steps.
Required scopes
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| idrequired | path | string | Quote identifier returned by the Organization API. |
Request body
| Field | Type | Description |
|---|---|---|
| sections[] | array<object> | Replaces all sections. Returns 409 when a generated travel charge owns a line; omit this family to retain that decision. |
| sections[].namerequired | string | Optional unless marked required. |
| sections[].description | string | null | Optional unless marked required. |
| sections[].sortOrder | integer | Optional unless marked required. |
| sections[].isCollapsed | boolean | Optional unless marked required. |
| sections[].showLineItems | boolean | Optional unless marked required. |
| sections[].isVisibleToCustomer | boolean | Optional unless marked required. |
| sections[].showLinesToCustomer | boolean | Optional unless marked required. |
| sections[].customerDisplayName | string | null | Optional unless marked required. |
| sections[].lines[] | array<object> | Optional unless marked required. |
| sections[].lines[].namerequired | string | Optional unless marked required. |
| sections[].lines[].description | string | null | Optional unless marked required. |
| sections[].lines[].productId | string | null | Optional unless marked required. |
| sections[].lines[].quantity | number | Optional unless marked required. |
| sections[].lines[].unit | string | Optional unless marked required. |
| sections[].lines[].unitPrice | number | Optional unless marked required. |
| sections[].lines[].unitCost | number | null | Optional unless marked required. |
| sections[].lines[].discountType | PERCENTAGE | FIXED | null | Optional unless marked required. |
| sections[].lines[].discountValue | number | null | Optional unless marked required. |
| sections[].lines[].isOptional | boolean | Optional unless marked required. |
| sections[].lines[].isSelected | boolean | Optional unless marked required. |
| sections[].lines[].isTaxable | boolean | Optional unless marked required. |
| sections[].lines[].isVisibleToCustomer | boolean | Optional unless marked required. |
| sections[].lines[].showPriceToCustomer | boolean | Optional unless marked required. |
| sections[].lines[].customerDisplayName | string | null | Optional unless marked required. |
| sections[].lines[].specs | JSON object | Optional unless marked required. |
| sections[].lines[].templateLineKey | string | null | Optional unless marked required. |
| pricing | object | Optional unless marked required. |
| pricing.methodology | ppw | cost_plus | package | tiered | Optional unless marked required. |
| pricing.tierId | string | null | Optional unless marked required. |
| pricing.pricePerWattInput | number | null | Optional unless marked required. |
| pricing.packagePriceInput | number | null | Optional unless marked required. |
| pricing.equipmentCostInput | number | Optional unless marked required. |
| pricing.laborCostInput | number | Optional unless marked required. |
| pricing.permitFeesInput | number | Optional unless marked required. |
| pricing.overheadInput | number | Optional unless marked required. |
| pricing.marginPercentInput | number | null | Optional unless marked required. |
| pricing.marginAmountInput | number | null | Optional unless marked required. |
| pricing.taxRateInput | number | null | Optional unless marked required. |
| pricing.overrides | JSON object | Optional unless marked required. |
| solar | object | Optional unless marked required. |
| solar.systemSizeKw | number | null | Optional unless marked required. |
| solar.annualProductionKwh | number | null | Optional unless marked required. |
| solar.panelCount | integer | null | Stands only on a Quote with no panels on its arrays. On a Quote that has them, the Quote's next calculation replaces it with the arrays' total. |
| solar.inverterType | string | null | Optional unless marked required. |
| solar.utilityCompany | string | null | Optional unless marked required. |
| solar.preSolarAnnualBillUsd | number | null | Optional unless marked required. |
| solar.preSolarAnnualKwh | number | null | Optional unless marked required. |
| solar.postSolarAnnualBillUsd | number | null | Optional unless marked required. |
| solar.projectedSavingsYear1 | number | null | Set by the Quote's calculation from its bill comparison: a value written here is replaced when the Quote is next calculated. |
| solar.solarOffsetPercent | number | null | Optional unless marked required. |
| solar.roofTypeId | string | null | Optional unless marked required. |
| solar.orgRoofTypeConfigId | string | null | Optional unless marked required. |
| solar.roofAgeYears | integer | null | Optional unless marked required. |
| solar.siteNotes | string | null | Optional unless marked required. |
| solar.siteLat | number | null | Optional unless marked required. |
| solar.siteLng | number | null | Optional unless marked required. |
| solar.details | JSON object | Optional unless marked required. |
| solar.rateScheduleId | string | null | The home's utility rate schedule today: the platform's or one of the organization's own. A generation supplier's schedule (one of a Community Choice Aggregator's, which prices generation alone) is refused with 400 VALIDATION_FAILED (details.issues path solar.rateScheduleId): choose the utility's schedule and set the supplier with generationProviderId. |
| solar.postSolarRateScheduleId | string | null | The rate schedule the home moves to with solar; null uses the one today's schedule names. The same rules as rateScheduleId: a generation supplier's schedule is refused with 400 VALIDATION_FAILED (details.issues path solar.postSolarRateScheduleId). |
| solar.baselineOverrideKwh | number | null | Optional unless marked required. |
| solar.baselineTerritory | string | null | The home's baseline territory, the code printed on its utility bill (PG&E uses a letter, e.g. X). Matched in any case to the territories of the utilities the Quote's rates bill from (today's, after solar, and the one today's moves solar customers to) and stored as the utility spells it. A code they don't have, or any code when none has a baseline table on file, is refused with 400 VALIDATION_FAILED (details.issues path solar.baselineTerritory); a Quote with no rate yet is checked against every table on file, and a value sent back unchanged is kept. Rates that bill a baseline allowance, such as PG&E E-1 and E-TOU-C, need it; null uses the organization's default. Without either, the solar calculation reports MISSING_BASELINE_TERRITORY. |
| solar.baselineAllElectric | boolean | True when electric heat is the home's main heat source: it gets the larger all-electric baseline allowance. |
| solar.generationProviderId | string | null | The Community Choice Aggregator the home buys its power from, such as Sonoma Clean Power, when the utility bill names one under Electric Generation Charges. Null, the default, means the utility supplies it. The calculation then bills the utility's delivery charges, the Power Charge Indifference Adjustment (PCIA) and franchise fee surcharge for the home's vintage, and the supplier's generation rates, and credits exports in each one's own way. It needs pciaVintage, and must bill through the utility of the Quote's rates, before and after solar. A supplier the organization can't see, one billing through another utility, or a rate change that would leave it on another utility's rate is refused with 400 VALIDATION_FAILED (details.issues path solar.generationProviderId). Null clears pciaVintage and generationProductKey too. |
| solar.pciaVintage | integer | null | The home's PCIA vintage: the year on its PG&E bill beside the Power Charge Indifference Adjustment. It sets that charge and the franchise fee surcharge. Required with generationProviderId; a year the utility has no PCIA rate for, or a year sent with no supplier, is refused with 400 VALIDATION_FAILED (details.issues path solar.pciaVintage). |
| solar.generationProductKey | string | null | The supplier's premium product the home buys, such as evergreen for Sonoma Clean Power EverGreen. Null is its standard product. A key the supplier doesn't offer, or one sent with no supplier, is refused with 400 VALIDATION_FAILED (details.issues path solar.generationProductKey). Changing generationProviderId without it drops it. |
| solar.arrays[] | array<object> | Replaces all arrays on the Quote. Refused with 409 when an existing design-managed array row was hand-edited in the builder, unless solar.force is true. |
| solar.arrays[].name | string | null | Optional unless marked required. |
| solar.arrays[].panelCountrequired | integer | Optional unless marked required. |
| solar.arrays[].panelWattagerequired | number | Optional unless marked required. |
| solar.arrays[].azimuth | number | null | Optional unless marked required. |
| solar.arrays[].tilt | number | null | Optional unless marked required. |
| solar.arrays[].shadingFactor | number | null | Optional unless marked required. |
| solar.arrays[].soilingFactor | number | null | Optional unless marked required. |
| solar.arrays[].snowLossFactor | number | null | Optional unless marked required. |
| solar.arrays[].arraySizeKw | number | null | Optional unless marked required. |
| solar.arrays[].annualProductionKwh | number | null | Optional unless marked required. |
| solar.arrays[].grossAnnualKwh | number | null | Optional unless marked required. |
| solar.arrays[].productionProfile | JSON object | Optional unless marked required. |
| solar.arrays[].panelProductId | string | null | Optional unless marked required. |
| solar.arrays[].details | JSON object | Optional unless marked required. |
| solar.arrays[].sortOrder | integer | Optional unless marked required. |
| solar.inverters[] | array<object> | Optional unless marked required. |
| solar.inverters[].typerequired | string | micro | hybrid | other | Optional unless marked required. |
| solar.inverters[].manufacturer | string | null | Optional unless marked required. |
| solar.inverters[].model | string | null | Optional unless marked required. |
| solar.inverters[].quantity | integer | Optional unless marked required. |
| solar.inverters[].powerKw | number | null | Optional unless marked required. |
| solar.inverters[].productId | string | null | Optional unless marked required. |
| solar.inverters[].sortOrder | integer | Optional unless marked required. |
| solar.batteries[] | array<object> | Optional unless marked required. |
| solar.batteries[].manufacturer | string | null | Optional unless marked required. |
| solar.batteries[].model | string | null | Optional unless marked required. |
| solar.batteries[].capacityKwhrequired | number | Optional unless marked required. |
| solar.batteries[].powerKw | number | null | Optional unless marked required. |
| solar.batteries[].quantity | integer | Optional unless marked required. |
| solar.batteries[].backupPercent | number | null | Compatibility input only. The value is not persisted; responses derive it from the Quote battery dispatch configuration. |
| solar.batteries[].estimatedBackupHours | number | null | Compatibility input only. The value is not persisted; responses calculate runtime from capacity, reserve, and load assumptions. |
| solar.batteries[].productId | string | null | Optional unless marked required. |
| solar.batteries[].details | JSON object | Free-form row details. details.unitRole is EXPANSION for an expansion unit (absent or MAIN for a main unit). details.onSite true marks the battery already at the home that an expansion-only sale adds to: it counts toward the bank's power, backup hours and expansion limit, but it is not sold — not priced, not on the proposal and not in the sold system. |
| solar.batteries[].sortOrder | integer | Optional unless marked required. |
| solar.systemDesign | object | Optional unless marked required. |
| solar.systemDesign.designMode | auto | manual | hybrid | Optional unless marked required. |
| solar.systemDesign.activePanels | JSON object | Retired Google-layout input. Accepted for compatibility but ignored on writes; reads preserve the stored value. Author panels in customRoofSegments. |
| solar.systemDesign.obstructions | JSON object | Optional unless marked required. |
| solar.systemDesign.setbacks | JSON object | Optional unless marked required. |
| solar.systemDesign.targetOffsetPercent | number | null | Optional unless marked required. |
| solar.systemDesign.selectedPanelProduct | string | null | Optional unless marked required. |
| solar.systemDesign.totalActivePanels | integer | null | Optional unless marked required. |
| solar.systemDesign.designSizeKw | number | null | Optional unless marked required. |
| solar.systemDesign.estimatedProductionKwh | number | null | Optional unless marked required. |
| solar.systemDesign.customRoofSegments | JSON object | Optional unless marked required. |
| solar.systemDesign.canvasImageBounds | JSON object | Optional unless marked required. |
| solar.force | boolean | Confirms overwriting hand-edited, design-managed array rows when replacing arrays. Without it, such a replacement is refused with 409 HAND_EDITED_ROW. Never stored. |
| financingScenarios[] | array<object> | Replaces all financing on the Quote. Each entry must be one of the Quote's offeredFundingSources from GET /quotes/{id}/content, and takes the saved option's terms unless it sends its own; custom terms and options the Quote does not offer return 400 FUNDING_SOURCE_NOT_OFFERED. Exactly one entry has isDefault true. |
| financingScenarios[].financingOptionId | string | null | A saved financing option the Quote offers. Null only for plain Cash, when the organization has no saved Cash option. |
| financingScenarios[].typerequired | CASH | LOAN | LEASE | PPA | PREPAID_TPO | Must match the saved financing option type. |
| financingScenarios[].namerequired | string | Optional unless marked required. |
| financingScenarios[].dealerFeePercentInput | number | null | Optional unless marked required. |
| financingScenarios[].dealerFeeTreatmentInput | ADD_ON_TOP | ABSORBED | Optional unless marked required. |
| financingScenarios[].loanTermMonthsInput | integer | null | Optional unless marked required. |
| financingScenarios[].aprInput | number | null | A loan's APR, within the lender's saved range. |
| financingScenarios[].downPaymentInput | number | null | A loan's cash down, at least the lender's minimum. Starts at that minimum. |
| financingScenarios[].leaseTermYearsInput | integer | null | Optional unless marked required. |
| financingScenarios[].leaseEscalatorPercentInput | number | null | Optional unless marked required. |
| financingScenarios[].ppaTermYearsInput | integer | null | Optional unless marked required. |
| financingScenarios[].ppaRatePerKwhInput | number | null | Optional unless marked required. |
| financingScenarios[].ppaEscalatorPercentInput | number | null | Optional unless marked required. |
| financingScenarios[].prepaidProviderFundingInput | number | null | A prepaid plan's provider funding. The customer's price comes down by at most the share of it the saved option passes on (a percent of the price), the rest staying the contractor's. |
| financingScenarios[].prepaidTpoFramingInput | PREPAID_PPA | PREPAID_LEASE | null | Optional unless marked required. |
| financingScenarios[].cashDiscountPercentInput | number | null | A cash plan's discount, as a percent of the price. On a solar Quote it comes off the Quote's total at its next calculation, with discountAmount and the milestone amounts following. |
| financingScenarios[].isDefault | boolean | The funding source on the proposal. Exactly one entry is. |
| paymentSchedule | object | Optional unless marked required. |
| paymentSchedule.fundingSources[] | array<object> | Optional unless marked required. |
| paymentSchedule.fundingSources[].id | string | Optional unless marked required. |
| paymentSchedule.fundingSources[].namerequired | string | Optional unless marked required. |
| paymentSchedule.fundingSources[].type | cash | financed | other | Optional unless marked required. |
| paymentSchedule.fundingSources[].inputType | fixed | percent | remainder | Optional unless marked required. |
| paymentSchedule.fundingSources[].inputValuerequired | number | Optional unless marked required. |
| paymentSchedule.fundingSources[].sortOrder | integer | Optional unless marked required. |
| paymentSchedule.milestones[] | array<object> | Optional unless marked required. |
| paymentSchedule.milestones[].namerequired | string | Optional unless marked required. |
| paymentSchedule.milestones[].typerequired | percent_of_total | percent_of_source_pool | percent_of_remaining | fixed | Optional unless marked required. |
| paymentSchedule.milestones[].inputValuerequired | number | Optional unless marked required. |
| paymentSchedule.milestones[].fundingSourceId | string | null | Optional unless marked required. |
| paymentSchedule.milestones[].capAmount | number | null | Optional unless marked required. |
| paymentSchedule.milestones[].dueUponCategory | DEPOSIT | DEPOSIT_SOLAR | CUSTOMER_PLAN_APPROVAL | SUBSTANTIAL_COMPLETION_OF_SOLAR_WORK | FINAL_SOLAR_PERMIT_RECEIVED | PTO_RECEIVED | OTHER | null | Optional unless marked required. |
| paymentSchedule.milestones[].dueOnEvent | string | null | Optional unless marked required. |
| paymentSchedule.milestones[].sortOrder | integer | Optional unless marked required. |
| incentives[] | array<object> | Optional unless marked required. |
| incentives[].typerequired | FEDERAL_TAX_CREDIT | STATE_TAX_CREDIT | UTILITY_REBATE | STATE_REBATE | SREC | LOCAL_INCENTIVE | OTHER | Optional unless marked required. |
| incentives[].namerequired | string | Optional unless marked required. |
| incentives[].valueTyperequired | PERCENT | FLAT | Optional unless marked required. |
| incentives[].valuerequired | number | Optional unless marked required. |
| incentives[].amountComputed | number | Optional unless marked required. |
| incentives[].isCustomerFacing | boolean | Optional unless marked required. |
| incentives[].notes | string | null | Optional unless marked required. |
| assumptions | object | Optional unless marked required. |
| assumptions.degradationRatePercent | number | Optional unless marked required. |
| assumptions.systemLifetimeYears | integer | Optional unless marked required. |
| assumptions.performanceRatio | number | Optional unless marked required. |
| assumptions.utilityEscalationPercent | number | null | Optional unless marked required. |
| assumptions.netMeteringPolicy | string | null | Optional unless marked required. |
| assumptions.discountRatePercent | number | null | Optional unless marked required. |
| assumptions.inflationRatePercent | number | null | Optional unless marked required. |
| assumptions.inverterReplacementYear | integer | null | Optional unless marked required. |
| assumptions.inverterReplacementCost | number | null | Optional unless marked required. |
| assumptions.gridEmissionsFactor | number | null | Optional unless marked required. |
| assumptions.overrides | JSON object | Optional unless marked required. |
| savings | object | Optional unless marked required. |
| savings.annualBillBeforeComputed | number | Optional unless marked required. |
| savings.monthlyBillBeforeComputed | number | Optional unless marked required. |
| savings.annualBillAfterComputed | number | Optional unless marked required. |
| savings.monthlyBillAfterComputed | number | Optional unless marked required. |
| savings.monthlyBillsBeforeComputed | JSON object | Optional unless marked required. |
| savings.monthlyBillsAfterComputed | JSON object | Optional unless marked required. |
| savings.annualSavingsYear1Computed | number | Optional unless marked required. |
| savings.lifetimeSavingsComputed | number | Optional unless marked required. |
| savings.lifetimeSavingsNetComputed | number | Optional unless marked required. |
| savings.savingsByScenarioComputed | JSON object | Optional unless marked required. |
| savings.paybackYearsComputed | number | null | Optional unless marked required. |
| savings.roiPercentComputed | number | null | Optional unless marked required. |
| savings.paybackByScenarioComputed | JSON object | Optional unless marked required. |
| savings.yearlyProjectionComputed | JSON object | Optional unless marked required. |
| savings.assumptionsSnapshot | JSON object | Optional unless marked required. |
| environmental | object | Optional unless marked required. |
| environmental.co2OffsetLbsComputed | number | Optional unless marked required. |
| environmental.co2OffsetTonsComputed | number | Optional unless marked required. |
| environmental.treesEquivalentComputed | number | Optional unless marked required. |
| environmental.carsOffRoadComputed | number | Optional unless marked required. |
| environmental.milesDrivenComputed | number | Optional unless marked required. |
| environmental.homesEquivalentComputed | number | Optional unless marked required. |
| environmental.annualCo2OffsetLbsComputed | number | Optional unless marked required. |
| environmental.annualTreesEquivalentComputed | number | Optional unless marked required. |
| environmental.gridEmissionsFactorUsed | number | null | Optional unless marked required. |
{
"sections": [
{
"name": "Solar installation",
"lines": [
{
"name": "PV installation",
"quantity": 1,
"unit": "each",
"unitPrice": 31500
}
]
}
],
"solar": {
"systemSizeKw": 8.4,
"arrays": [
{
"panelCount": 21,
"panelWattage": 400
}
],
"batteries": [
{
"manufacturer": "Example Energy",
"capacityKwh": 13.5,
"quantity": 1
}
],
"systemDesign": {
"designMode": "manual",
"totalActivePanels": 21,
"designSizeKw": 8.4
}
}
}Responses
Updated Quote builder content envelope with changed metadata
#/components/schemas/OrgApiResourceEnvelope
Validation or builder constraint error, or FUNDING_SOURCE_NOT_OFFERED when a financingScenarios entry is custom terms or an option the Quote does not offer
#/components/schemas/OrgApiError
Missing or invalid organization API key
#/components/schemas/OrgApiError
Organization billing access or an endpoint-specific business quota denied the request
#/components/schemas/OrgApiError
Missing quotes:content:write scope
#/components/schemas/OrgApiError
Quote not found
#/components/schemas/OrgApiError
QUOTE_DESIGN_LOCKED when the Quote was accepted; otherwise the Quote requires revision/duplicate flow, managed travel prevents section replacement, or array replacement would overwrite hand-edited design rows (HAND_EDITED_ROW); reconcile edits before explicitly confirming with solar.force
#/components/schemas/OrgApiError
Rate limit exceeded
#/components/schemas/OrgApiError
Errors
Static examples
curl -X PATCH "$SOLAROS_BASE_URL/api/v1/quotes/$SOLAROS_QUOTE/content" \
-H "Authorization: Bearer $SOLAROS_API_KEY" \
-H "Content-Type: application/json" \
--data '{"sections":[{"name":"Solar installation","lines":[{"name":"PV installation","quantity":1,"unit":"each","unitPrice":31500}]}],"solar":{"systemSizeKw":8.4,"arrays":[{"panelCount":21,"panelWattage":400}],"batteries":[{"manufacturer":"Example Energy","capacityKwh":13.5,"quantity":1}],"systemDesign":{"designMode":"manual","totalActivePanels":21,"designSizeKw":8.4}}}'