Building with an AI agent? Start with Connect to the WattChop MCP server.

The WattChop Partner API exposes our optimization engine as a sizing primitive. Hand it a tariff, a load profile, and a system size — it returns the modeled economics for that exact configuration: savings with and without optimized dispatch, the recommended battery cadence, and the engine provenance behind the numbers. Every result is Powered by WattChop.

Think of it the way a typesetting engine relates to a finished document. This API is the engine — rigorous, deterministic, one configuration at a time. The customer-facing story (the curve, the recommendation, the report) is composed on top. On WattChop.com we compose it for you; as a partner, you decide how much to build, and the primitive gives you clean inputs to build from.

This article is a guided tour. Open the playground in a second tab and follow along — each step is a real call you can run right now.

▶ Open the playground

Launch the interactive Partner API docs in a new tab: app.wattchop.com/api/v1/docs →. Keep this article open alongside it — we'll walk through each endpoint together.

Before You Start — Authorize

The Partner API authenticates with a partner bearer token. This is not a WattChop app login and not a wc_live_ ingest key — it is a token issued to you directly. Yours is in your invitation email.

  1. Open the playground.
  2. Click the Authorize button (top right).
  3. Paste your token and confirm. Every call below now carries it automatically.

⚡ If a call returns 401, the token isn't authorized — re-check the Authorize box. A WattChop login token will always 401 here; use the partner token from your invitation.

Step 1 — See What You Can Run: GET /v1/samples

You don't need any data of your own to start. We bundle three real load profiles, each paired with a test tariff that exercises the engine's economics for that load shape.

In the playground, expand GET /api/v1/samples, click Try it out, then Execute. You'll get back the manifest of available profiles:

sample_site_idLoad ProfileSuggested Tariff
sample_1Residential – Single Familysce_tou_d_prime
sample_2Commercial – Daytime Officesce_tou_gs3e
sample_3Industrial – Two Shiftpge_b20p

Three test tariffs are accepted: sce_tou_d_prime, sce_tou_gs3e (SCE), and pge_b20p (PG&E). Any other tariff_code is rejected with the allowed list, so you always know your options.

Note the shape: tariff_code takes the lowercase API slug, never the utility's display code. The mapping:

UtilityDisplay codetariff_code (API slug)
SCETOU-D-PRIMEsce_tou_d_prime
SCETOU-GS-3-Esce_tou_gs3e
PG&EB-20-Ppge_b20p

Step 2 — Score One System: POST /v1/sizing

This is the workhorse. Give it a tariff, a load, and a fixed system size — it scores that exact system synchronously and returns the result on the same request — no polling. In our own runs a score typically comes back in around 20 seconds; a denser or messier load profile takes longer.

A note on how this endpoint receives data: it is multipart/form-data, not raw JSON. In the playground that's invisible — you just fill the request field. Expand POST /api/v1/sizingTry it out. You'll see two inputs:

Paste this into the request field and click Execute:

{
  "tariff_code": "sce_tou_gs3e",
  "intervals": { "kind": "sample", "sample_site_id": "sample_2" },
  "pv_kw": 90,
  "battery_kwh": 100
}

💡 Both pv_kw and battery_kwh are required on this endpoint. You are dictating the system, so the engine scores exactly what you specify — it does not pick a size for you. Omitting either returns a 400 (that's what the sweep in Step 6 is for).

The endpoint's own error says it best: “POST /v1/sizing requires BOTH pv_kw and battery_kwh (synchronous fixed-config scoring). To let the optimizer choose sizes, or for a multi-scenario study, use POST /v1/sizing/sweep.”

Step 3 — Read What Came Back

The response is a sizing envelope. The fields worth your attention on a first read:

FieldWhat it tells you
scenarios[]The scored system(s). Each carries savings_static and savings_optimized — see below.
savings_staticAnnual savings from the system without optimized dispatch.
savings_optimizedAnnual savings with WattChop tariff-smart dispatch. The gap between the two is the optimization uplift — frequently a large fraction of total savings.
dispatchThe recommended battery cadence and the reasoning behind it, plus the annual uplift dispatch contributes.
engineProvenance: engine_revision and generated_at. Every result is traceable to a specific engine build.
attributionThe Powered by WattChop display block — required on any surface where you show these results.

The savings_static vs. savings_optimized spread is the headline: it's the dollar value of dispatch intelligence on top of the hardware. That gap is the WattChop difference.

A real result looks like this (excerpted):

"scenarios": [{
  "label": "Solar + Battery",
  "system": { "pv_kw": 412.5, "battery_kwh": 1080, "battery_kw": 540 },
  "savings_static":    198400,   // sizing alone
  "savings_optimized": 257200,   // with WattChop Dispatch
  "optimization_uplift": 58800   // the difference dispatch makes
}],
"dispatch": {
  "recommended_cadence": "monthly",
  "annual_uplift": 58800,
  "cadence_reasoning": "Seasonal tariff structure with monthly demand peaks."
}

Here dispatch adds $58,800/yr on top of the $198,400 the hardware saves on its own — a ~30% lift from intelligence alone, no extra equipment.

Step 4 — Change the Size, Build the Curve

Re-run POST /api/v1/sizing with a different battery_kwh — try 150, then 200 — holding everything else fixed. Each call scores one point. Call it across a range of sizes and you compose the diminishing-returns curve yourself: savings vs. system size, with the knee where each added kWh stops paying for itself.

This is the core pattern: the curve is a product you build from the primitive, calling sync N times. The single-point engine is intentionally simple so you control the resolution and presentation.

Sizing energy vs. power — the C-rate

A battery has two dimensions: energy (kWh) and power (kW). Their ratio is the C-rate, set by the optional duration_hours field. The default is 2.0 hours = 0.5C, matching the WattChop app — a 100 kWh battery at 2.0h is rated for 50 kW of power.

{
  "tariff_code": "sce_tou_gs3e",
  "intervals": { "kind": "sample", "sample_site_id": "sample_2" },
  "pv_kw": 90,
  "battery_kwh": 150,
  "duration_hours": 2.0
}

Practical guidance: for most commercial TOU and peak-shaving loads, the binding constraint is stored energy, not discharge power. Size energy first — set battery_kwh to cover the load you're shifting, then confirm the implied power (kWh ÷ duration_hours) clears your peak target. When you sweep sizes, vary battery_kwh and leave duration_hours alone unless you're deliberately modeling a higher-power pack.

Step 5 — Bring Your Own Load Data

Ready to score a real site? Swap the bundled sample for your own interval file.

  1. Expand GET /api/v1/templateTry it outExecute, and download wattchop-load-template.csv. (This endpoint is public — no token needed.)
  2. The format is two columns: timestamp_utc (ISO 8601; the Z suffix is optional) and kwh (non-negative energy per interval).
  3. A full-year upload must contain exactly 35,040 rows (one year of 15-minute intervals). Max file size 5 MB.
  4. Back on POST /api/v1/sizing, set the request to use an uploaded profile, and attach your file in the intervals_csv picker:
{
  "tariff_code": "pge_b20p",
  "intervals": { "kind": "upload" },
  "pv_kw": 250,
  "battery_kwh": 500
}

Note intervals.kind is now "upload" instead of "sample", and there's no sample_site_id. The file in intervals_csv supplies the load.

💡 No interval data at all? If the site only has utility bills, the Synthesizer can model a full year of 15-minute intervals from twelve monthly kWh totals — see “No Interval Data? Synthesize It” below.

Step 6 — The Premium Sweep: POST /v1/sizing/sweep

Don't want to run and compose the curve yourself? The sweep does the size search for you and returns the full curve, the recommended configuration, and the selection rationale.

The honest tradeoff: a sweep runs a multi-scenario search, so it is asynchronous. In our own runs a sweep takes one to three minutes depending on how complex the load profile is, and it always hands back a run handle you can poll — so you never have to guess whether it is still working:

  1. Expand POST /api/v1/sizing/sweep, fill the request (omit the fixed sizes — that's the point), and Execute. You get back 202 with a run_id and a poll_url.
  2. Expand GET /api/v1/sizing/sweep/{run_id}, paste the run_id, and Execute to poll. While running you'll see a status and progress; when complete, the full envelope — including the diminishing_returns curve — comes back.
{
  "tariff_code": "sce_tou_gs3e",
  "intervals": { "kind": "sample", "sample_site_id": "sample_2" }
}

Position it this way: sync is the composable primitive for interactive experiences; sweep is the hands-off premium path where we run the optimization and hand you the answer. Many partners use both.

⚡ Two things that catch first-time testers

Sample IDs go in the request body, never in the URL. sample_2 is a value for intervals.sample_site_id inside POST /v1/sizing — not a path parameter. Putting it in a path returns an error.

Skip /v1/sizing/preview/{project_id} for now. It fetches an existing optimized project from your account and needs a real project UUID. As a new partner you don't have one yet — start with POST /v1/sizing and a bundled sample (Step 2).

No Interval Data? Synthesize It

Sometimes all a site has is twelve numbers off its utility bills. The Synthesizer turns those twelve numbers into a full year of 15-minute interval data shaped like the building you pick — an office goes dark on weekends, a place of worship peaks on Sunday, a data center runs flat. The output is a CSV in the exact WattChop interval-upload format, so it feeds straight back into the loop you already know. It is modeled, not measured — an estimate from an industry model — and every surface of the response says so.

Two endpoints, both public. No partner token, no Authorize step:

EndpointMethodPurpose
/api/v1/building-typesGETThe building-type catalog. Cacheable for an hour; not rate limited.
/api/v1/synthesizePOSTTwelve monthly bills → a year of intervals. Rate limited to 20 requests per hour per IP.

Nothing is stored. No project is created, no data is written, no run is recorded — the response is the product.

The Loop

  1. Synthesize. POST twelve monthly kWh totals (January first) and a building type.
  2. Download. The response is a CSV with the header timestamp_utc,kwh — the same interval-upload template from Step 5.
  3. Feed it back. Attach the file as intervals_csv on POST /v1/sizing with intervals.kind set to "upload", or upload it to a WattChop project. No editing, no reformatting — the file is byte-compatible with the upload template by design, and the round trip is verified against production.

Pick a Building Type — Copy It, Don't Retype It

⚡ The canonical building-type names use an EN DASH (–, U+2013), not a hyphen, between the sector and the subtype. Copy them from the table below or from GET /api/v1/building-types — don't retype them.

The API is forgiving in specific, bounded ways: a plain hyphen, an em dash, or a minus sign used as the separator is accepted and canonicalized, as are letter case, extra whitespace, and slash spacing. School - K-12 becomes School – K-12 — the separator is fixed, and the hyphen inside K-12 is left alone. Anything the API cannot resolve is rejected with a 422 carrying the full allowed list. It never guesses.

building_type (canonical)DisplaySectorArchetype
Commercial – Daytime OfficeOfficecommercialCooling-Dominated
Retail – Extended HoursRetailcommercialCooling-Dominated
Restaurant – Full ServiceRestaurantcommercialMixed-Use
Hospitality – HotelHotelcommercialMixed-Use
Grocery / SupermarketGrocerycommercialMixed-Use
Entertainment – VenueVenuecommercialCooling-Dominated
Fitness / GymGymcommercialCooling-Dominated
Mixed-Use ComplexMixed-UsecommercialCooling-Dominated
Self-Storage FacilitySelf-StoragecommercialLighting-Dominated
Place of WorshipWorshipcommercialLighting-Dominated
Healthcare – HospitalHospitalcommercialMixed-Use
Healthcare – Clinic / Medical OfficeMedical OfficecommercialCooling-Dominated
School – K-12K-12 SchoolcommercialCooling-Dominated
School – UniversityUniversitycommercialCooling-Dominated
Laboratory / R&DLab / R&DcommercialMixed-Use
Industrial – 24/7 Process24/7 IndustrialindustrialBase-Load Flat
Industrial – Two ShiftTwo-Shift IndustrialindustrialProcess-Driven
Cold Storage – RefrigerationCold StorageindustrialBase-Load Flat
Data CenterData CenterindustrialBase-Load Flat
Warehouse – Light DayWarehouseindustrialProcess-Driven
Cannabis / Indoor GrowIndoor GrowindustrialBase-Load Flat
Residential – Single FamilySingle FamilyresidentialHeating-Dominated
Residential – Multi-FamilyMulti-FamilyresidentialHeating-Dominated
EV Charging – WorkplaceWorkplace EVcommercialProcess-Driven
EV Charging – Public / DC FastDC Fast ChargingcommercialProcess-Driven
Agriculture – Irrigation PumpingIrrigationagriculturalProcess-Driven
Municipal – Water/WastewaterWater/WastewatermunicipalBase-Load Flat
OtherOthercommercialCooling-Dominated

Legacy short aliases (office, church, gym, warehouse, and friends) are still accepted and map to their canonical names — churchPlace of Worship, gymFitness / Gym. The live map is the legacy_aliases field of GET /v1/building-types; read it from there rather than hard-coding it.

One special case: "Other" requires building_type_detail — a short free-text description of the building, 2–120 characters. It is how the catalog grows: whatever you type there tells us which profile to build next.

Request Fields

FieldTypeDefaultNotes
building_typestringRequired. Canonical name or legacy alias.
building_type_detailstringnullRequired when type is Other. 2–120 characters.
monthly_kwhfloat[12]Required. January first. Each must be > 0.
peak_kwfloatnullAnnual maximum demand — see the ceiling note below.
monthly_peak_kwfloat[12]nullAdvanced: a demand reading per month.
lat / lngfloatnullWeather modulation only. Never affects the clock.
tz_namestringAmerica/Los_AngelesIANA zone. Declarative — see the timestamps note below.
yearintlast full calendar yearMust not be a leap year.
formatstring"csv""csv" or "json".

peak_kw Is an Annual Ceiling, Not a Monthly Target

peak_kw is the highest demand the site reached in the year — the number off the worst bill. It is applied as a ceiling on every month, and as a shaping target for only the one month that naturally carries the annual peak. The other eleven months keep their natural seasonal shape.

That is deliberate. Treating an annual figure as a monthly target inflates every shoulder month toward the summer peak, flattens seasonality, and — on a demand-charge tariff — invents savings that do not exist. If you genuinely have twelve monthly demand readings, send monthly_peak_kw instead; each one shapes its own month.

Try It

curl -s -X POST https://app.wattchop.com/api/v1/synthesize \
  -H 'Content-Type: application/json' \
  -o synth_gym.csv -D - \
  -d '{
    "building_type": "Fitness / Gym",
    "monthly_kwh": [12000,12000,15000,18000,24000,30000,34000,34000,28000,20000,14000,12000]
  }'

The response headers carry the provenance:

Content-Disposition: attachment; filename="wattchop_synthesized_fitness-gym_2025.csv"
X-WattChop-Synthesized: Synthesized by WattChop - modeled load profile, not metered data.
X-WattChop-Load-Basis: synthesized
X-WattChop-Clock-Convention: site_local_wall_clock

And the body is the upload template exactly — no comment lines, 35,040 data rows:

timestamp_utc,kwh
2025-01-01T00:00:00,1.7233
2025-01-01T00:15:00,1.6575
2025-01-01T00:30:00,1.6382

From here, synth_gym.csv drops straight into the canonical upload call shown in “Calling It From Code” above. Add "format": "json" to get the intervals plus a metadata block instead of a file — useful when you want annual_kwh, peak_kw, and the confidence labeling alongside the data.

Timestamps Are Site-Local Wall Clock — Not UTC

The timestamp_utc column carries site-local wall-clock time, not UTC. The column keeps that name because it is what the WattChop upload template calls it, and round-trip compatibility outranks a tidier name. The response says so explicitly: metadata.clock_convention is "site_local_wall_clock", metadata.dst_modelled is false, and the X-WattChop-Clock-Convention header repeats it.

tz_name is declarative: it records the locale the profile is intended for and is echoed back, but it does not shift the digits, and the series has no daylight-saving transitions. A year is always 35,040 intervals, starting YYYY-01-01T00:00:00 and ending YYYY-12-31T23:45:00. Leap years are refused (leap_year_unsupported) because they produce 35,136 intervals, which the upload path will not accept.

Modeled Data, Labeled as Modeled

Synthesized data is an estimate from an industry model of a building like this one — not measured, not metered. The API states that in four independent places:

WhereValue
metadata.load_basis"synthesized"
metadata.confidence / confidence_label"low" / "Estimated (industry model)"
metadata.watermark"Synthesized by WattChop - modeled load profile, not metered data."
HeadersX-WattChop-Synthesized, X-WattChop-Load-Basis

The CSV body itself carries no watermark — it stays pristine so it round-trips. The provenance rides the response headers, the JSON metadata, and the download filename (wattchop_synthesized_<type>_<year>.csv). Keep the filename when you save the file. The label travels downstream, too: savings scored from a synthesized CSV are estimates from an industry model — the file's provenance travels with the dollars. Once real interval data or a meter arrives, it supersedes the model entirely; WattChop ranks measured data above modeled.

Synthesizer Error Codes

Every failure returns a stable detail.code, so you can branch on a string rather than parsing prose:

CodeStatusMeaning
unknown_building_type422Unresolvable name. allowed carries all 28 canonical values.
building_type_detail_required422"Other" without a description.
peak_unreachable422This load shape cannot spike that sharply — a flat 24/7 load simply does not. achievable_kw is the actionable field: surface it in any UI.
peak_infeasible422The peak is too low to hold the monthly energy. Pure physics — the message states the arithmetic (hours × kW vs. kWh).
unknown_timezone422IANA identifiers only — America/Los_Angeles, not PST.
leap_year_unsupported422Leap years produce 35,136 intervals; the upload path takes 35,040.
rate_limited42920 synthesize requests per hour per IP. A Retry-After header and retry_after_seconds tell you when.

Input validation uses the same pattern: monthly_kwh_length, monthly_kwh_not_positive, monthly_kwh_not_a_number, monthly_kwh_not_finite, monthly_kwh_ceiling, building_type_required, building_type_detail_length, year_out_of_range, and unknown_format.

Endpoint Summary

EndpointMethodPurpose
/api/v1/samplesGETList bundled sample load profiles
/api/v1/templateGETDownload the CSV upload template (public)
/api/v1/sizingPOSTScore one fixed system, synchronously — result returns on the same request
/api/v1/sizing/sweepPOSTSubmit a multi-size sweep (async, premium) → 202 + run_id
/api/v1/sizing/sweep/{run_id}GETPoll a sweep run until complete
/api/v1/sizing/preview/{project_id}GETFetch the envelope for an existing optimized project (needs a real project UUID — not part of the sample walkthrough)
/api/v1/building-typesGETSynthesizer building-type catalog (public, no token)
/api/v1/synthesizePOSTTwelve monthly bills → a year of modeled 15-minute intervals (public, rate limited)

Request Reference

FieldTypeRequiredNotes
tariff_codestringYesOne of sce_tou_d_prime, sce_tou_gs3e, pge_b20p
intervals.kindstringYes"sample" or "upload"
intervals.sample_site_idstringIf samplesample_1, sample_2, or sample_3
pv_kwnumberSync onlyRequired on /v1/sizing; omitted on sweep
battery_kwhnumberSync onlyRequired on /v1/sizing; omitted on sweep
duration_hoursnumberNoBattery duration / C-rate. Default 2.0h (0.5C)
intervals_csvfileIf uploadMultipart file part; 35,040 rows, ≤5 MB

Calling It From Code

Outside the playground, the request is a multipart form: the JSON goes in a request form field, and the optional CSV is a file part. Don't set Content-Type yourself — let your HTTP client set the multipart boundary.

curl -X POST https://app.wattchop.com/api/v1/sizing \
  -H "Authorization: Bearer $WC_TOKEN" \
  -F 'request={"tariff_code":"sce_tou_gs3e","intervals":{"kind":"sample","sample_site_id":"sample_2"},"pv_kw":90,"battery_kwh":100}'

$WC_TOKEN is your partner bearer token — the one from your invitation email. For a token or integration support, reach partners@wattchop.com.

For an upload-kind call — here scoring a synthesized gym profile straight from the Synthesizer below — write the JSON to a file and reference it, typing both parts explicitly. This exact invocation is verified against production:

printf '{"tariff_code":"sce_tou_gs3e","intervals":{"kind":"upload"},"pv_kw":50,"battery_kwh":100}' > req.json
curl -X POST https://app.wattchop.com/api/v1/sizing \
  -H "Authorization: Bearer $WC_TOKEN" \
  -F "request=<req.json;type=application/json" \
  -F "intervals_csv=@synth_gym.csv;type=text/csv"

⚡ Two things that trip up first integrations: (1) it's multipart — sending raw JSON with -d returns 422; use -F 'request=...'. (2) Auth is the partner token, not a WattChop login token.

Error Handling

HTTP StatusMeaningAction
200Sync result returnedRead the sizing envelope
202Sweep acceptedPoll poll_url until complete
400Missing required size on syncSupply both pv_kw and battery_kwh, or use the sweep
401Token missing or not authorizedRe-check Authorize; use the partner token from your invitation
422Invalid request bodyCheck the JSON, the tariff_code allow-list, and that you used multipart (not raw JSON)

Questions?

Ready to build?

Open the playground at app.wattchop.com/api/v1/docs → and run Step 2 with a bundled sample. For a partner token, integration support, or to discuss volume access, reach partners@wattchop.com.