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.
- Open the playground.
- Click the Authorize button (top right).
- 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_id | Load Profile | Suggested Tariff |
|---|---|---|
sample_1 | Residential – Single Family | sce_tou_d_prime |
sample_2 | Commercial – Daytime Office | sce_tou_gs3e |
sample_3 | Industrial – Two Shift | pge_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:
| Utility | Display code | tariff_code (API slug) |
|---|---|---|
| SCE | TOU-D-PRIME | sce_tou_d_prime |
| SCE | TOU-GS-3-E | sce_tou_gs3e |
| PG&E | B-20-P | pge_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/sizing → Try it out. You'll see two inputs:
request— a JSON object (as text). This holds the tariff, load, and sizes.intervals_csv— an optional file upload. Leave it empty for now; we're using a bundled sample.
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:
| Field | What it tells you |
|---|---|
scenarios[] | The scored system(s). Each carries savings_static and savings_optimized — see below. |
savings_static | Annual savings from the system without optimized dispatch. |
savings_optimized | Annual savings with WattChop tariff-smart dispatch. The gap between the two is the optimization uplift — frequently a large fraction of total savings. |
dispatch | The recommended battery cadence and the reasoning behind it, plus the annual uplift dispatch contributes. |
engine | Provenance: engine_revision and generated_at. Every result is traceable to a specific engine build. |
attribution | The 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.
- Expand GET /api/v1/template → Try it out → Execute, and download
wattchop-load-template.csv. (This endpoint is public — no token needed.) - The format is two columns:
timestamp_utc(ISO 8601; theZsuffix is optional) andkwh(non-negative energy per interval). - A full-year upload must contain exactly 35,040 rows (one year of 15-minute intervals). Max file size 5 MB.
- Back on POST /api/v1/sizing, set the
requestto use an uploaded profile, and attach your file in theintervals_csvpicker:
{
"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:
- Expand POST /api/v1/sizing/sweep, fill the
request(omit the fixed sizes — that's the point), and Execute. You get back 202 with arun_idand apoll_url. - 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 thediminishing_returnscurve — 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:
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/building-types | GET | The building-type catalog. Cacheable for an hour; not rate limited. |
/api/v1/synthesize | POST | Twelve 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
- Synthesize. POST twelve monthly kWh totals (January first) and a building type.
- Download. The response is a CSV with the header
timestamp_utc,kwh— the same interval-upload template from Step 5. - Feed it back. Attach the file as
intervals_csvonPOST /v1/sizingwithintervals.kindset 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) | Display | Sector | Archetype |
|---|---|---|---|
Commercial – Daytime Office | Office | commercial | Cooling-Dominated |
Retail – Extended Hours | Retail | commercial | Cooling-Dominated |
Restaurant – Full Service | Restaurant | commercial | Mixed-Use |
Hospitality – Hotel | Hotel | commercial | Mixed-Use |
Grocery / Supermarket | Grocery | commercial | Mixed-Use |
Entertainment – Venue | Venue | commercial | Cooling-Dominated |
Fitness / Gym | Gym | commercial | Cooling-Dominated |
Mixed-Use Complex | Mixed-Use | commercial | Cooling-Dominated |
Self-Storage Facility | Self-Storage | commercial | Lighting-Dominated |
Place of Worship | Worship | commercial | Lighting-Dominated |
Healthcare – Hospital | Hospital | commercial | Mixed-Use |
Healthcare – Clinic / Medical Office | Medical Office | commercial | Cooling-Dominated |
School – K-12 | K-12 School | commercial | Cooling-Dominated |
School – University | University | commercial | Cooling-Dominated |
Laboratory / R&D | Lab / R&D | commercial | Mixed-Use |
Industrial – 24/7 Process | 24/7 Industrial | industrial | Base-Load Flat |
Industrial – Two Shift | Two-Shift Industrial | industrial | Process-Driven |
Cold Storage – Refrigeration | Cold Storage | industrial | Base-Load Flat |
Data Center | Data Center | industrial | Base-Load Flat |
Warehouse – Light Day | Warehouse | industrial | Process-Driven |
Cannabis / Indoor Grow | Indoor Grow | industrial | Base-Load Flat |
Residential – Single Family | Single Family | residential | Heating-Dominated |
Residential – Multi-Family | Multi-Family | residential | Heating-Dominated |
EV Charging – Workplace | Workplace EV | commercial | Process-Driven |
EV Charging – Public / DC Fast | DC Fast Charging | commercial | Process-Driven |
Agriculture – Irrigation Pumping | Irrigation | agricultural | Process-Driven |
Municipal – Water/Wastewater | Water/Wastewater | municipal | Base-Load Flat |
Other | Other | commercial | Cooling-Dominated |
Legacy short aliases (office, church, gym, warehouse, and friends) are still accepted and map to their canonical names — church → Place of Worship, gym → Fitness / 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
| Field | Type | Default | Notes |
|---|---|---|---|
building_type | string | — | Required. Canonical name or legacy alias. |
building_type_detail | string | null | Required when type is Other. 2–120 characters. |
monthly_kwh | float[12] | — | Required. January first. Each must be > 0. |
peak_kw | float | null | Annual maximum demand — see the ceiling note below. |
monthly_peak_kw | float[12] | null | Advanced: a demand reading per month. |
lat / lng | float | null | Weather modulation only. Never affects the clock. |
tz_name | string | America/Los_Angeles | IANA zone. Declarative — see the timestamps note below. |
year | int | last full calendar year | Must not be a leap year. |
format | string | "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:
| Where | Value |
|---|---|
metadata.load_basis | "synthesized" |
metadata.confidence / confidence_label | "low" / "Estimated (industry model)" |
metadata.watermark | "Synthesized by WattChop - modeled load profile, not metered data." |
| Headers | X-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:
| Code | Status | Meaning |
|---|---|---|
unknown_building_type | 422 | Unresolvable name. allowed carries all 28 canonical values. |
building_type_detail_required | 422 | "Other" without a description. |
peak_unreachable | 422 | This 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_infeasible | 422 | The peak is too low to hold the monthly energy. Pure physics — the message states the arithmetic (hours × kW vs. kWh). |
unknown_timezone | 422 | IANA identifiers only — America/Los_Angeles, not PST. |
leap_year_unsupported | 422 | Leap years produce 35,136 intervals; the upload path takes 35,040. |
rate_limited | 429 | 20 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
| Endpoint | Method | Purpose |
|---|---|---|
/api/v1/samples | GET | List bundled sample load profiles |
/api/v1/template | GET | Download the CSV upload template (public) |
/api/v1/sizing | POST | Score one fixed system, synchronously — result returns on the same request |
/api/v1/sizing/sweep | POST | Submit a multi-size sweep (async, premium) → 202 + run_id |
/api/v1/sizing/sweep/{run_id} | GET | Poll a sweep run until complete |
/api/v1/sizing/preview/{project_id} | GET | Fetch the envelope for an existing optimized project (needs a real project UUID — not part of the sample walkthrough) |
/api/v1/building-types | GET | Synthesizer building-type catalog (public, no token) |
/api/v1/synthesize | POST | Twelve monthly bills → a year of modeled 15-minute intervals (public, rate limited) |
Request Reference
| Field | Type | Required | Notes |
|---|---|---|---|
tariff_code | string | Yes | One of sce_tou_d_prime, sce_tou_gs3e, pge_b20p |
intervals.kind | string | Yes | "sample" or "upload" |
intervals.sample_site_id | string | If sample | sample_1, sample_2, or sample_3 |
pv_kw | number | Sync only | Required on /v1/sizing; omitted on sweep |
battery_kwh | number | Sync only | Required on /v1/sizing; omitted on sweep |
duration_hours | number | No | Battery duration / C-rate. Default 2.0h (0.5C) |
intervals_csv | file | If upload | Multipart 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 Status | Meaning | Action |
|---|---|---|
| 200 | Sync result returned | Read the sizing envelope |
| 202 | Sweep accepted | Poll poll_url until complete |
| 400 | Missing required size on sync | Supply both pv_kw and battery_kwh, or use the sweep |
| 401 | Token missing or not authorized | Re-check Authorize; use the partner token from your invitation |
| 422 | Invalid request body | Check 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.