From credentials to a rendered consumption chart: the full integration path in one article.
From credentials to a rendered consumption chart. Six phases, in order, with the exact call to make at each step and the exact field that tells you what to do next.
client_id + client_secret and your API host from Eliq/v3/users/{userId}/locationsAlmost every integration question, which endpoint first?, why is this empty?, resolves once these two pictures are in your head.
The Data Management API is where your platform pushes customers, addresses, meters, contracts and readings into Eliq. The Insights API is where your app reads analytics back out. They are different hosts, different audiences on the token, and different date conventions. Nothing you read from Insights exists until you have written it through Data Management.
Eliq models three things. A user is a person or customer. A location is an address or geo-point. A meter is what records energy at that location, with a fuel and a direction. A user has zero or more locations; each location has zero or more meters. Your first integration job is translating your own model into that shape.
The location is the unit of everything the app renders. Once your app knows a locationId, nearly every Insights endpoint is /v3/locations/{locationId}/…, consumption, profile, disaggregation, forecast, market price, advice. Users exist mainly to answer “which locations may this person see?”
Both APIs let you use your own identifiers, but they name them in opposite ways. Get this straight before you write a line of code.
| API | id means | Your own key is | You use it for |
|---|---|---|---|
| Data Management what you write | Your identifier, you choose it and put it in the path | id | Idempotent upserts: PUT /users/{yourUserId} twice is one user |
| Insights what you read | Eliq’s numeric identifier | ext_ref | Every path segment: /v3/locations/16644191/consumption |
Store the mapping. A Data Management write returnseliq_idalongside yourid. Thateliq_idis the number the Insights API wants. Persist the pair when you provision, it saves a lookup on every app session. If you didn’t, you can recover it withGET /v3/users?extref={yours}orGET /v3/locations?extRef={yours}.
OAuth 2.0 client credentials against Eliq’s Auth API. One endpoint, two shapes of request.
UAT: https://auth-api-uat.eliq.com/oauth/token
No end user involved. This is what you use to provision data. Both application/json and application/x-www-form-urlencoded are accepted, so a standard OAuth client library works unchanged.
# Provisioning: audience is the Data Management API curl -X POST https://auth-api.eliq.com/oauth/token \ -H 'Content-Type: application/json' \ -d '{ "grant_type": "client_credentials", "client_id": "<your client_id>", "client_secret": "<your client_secret>", "aud": "data-management-api" }' # → { "access_token": "eyJhbG…", "token_type": "Bearer", "expires_in": 3600 }
Adds sub (the user) and sub_type. The resulting token is scoped to one user, so the Insights API will only ever return that user’s locations. This is the token you hand to the app.
# Your app calls YOUR backend; YOUR backend calls this. curl -X POST https://auth-api.eliq.com/oauth/token \ -H 'Content-Type: application/json' \ -d '{ "grant_type": "client_credentials", "client_id": "<your client_id>", "client_secret": "<your client_secret>", "aud": "insights-api", "sub": "<the user id>", "sub_type": "user", "issue_refresh_token": true }' # → { "access_token": "…", "token_type": "Bearer", "expires_in": 3600, # "refresh_token": "…", "refresh_token_expires_in": 2592000 }
Never ship the secret. Client credentials must not be embedded in a mobile or browser app, and must not be reachable from one. Expose a single endpoint on your own backend,GET /eliq-tokenfor the signed-in user, that mints a delegated token and returns only that. Every call below then travels asAuthorization: Bearer <access_token>.
Insights API requests go to your host, not a shared one: https://<your-client-key>.insights-api.eliq.com/v3. Eliq resolves which client you are from the hostname and cross-checks it against the token. A right token on the wrong host fails, and an unrecognised host returns 404 INVALID_REQUEST_HOST, so make the base URL a single configuration value per environment and never hardcode it in the app bundle.
| Purpose | Base URL |
|---|---|
| Token | https://auth-api.eliq.com |
| Data Management | https://data-management-api.eliq.com/integration/api/v1/ |
| Insights | The host Eliq issues you, ending in /v3, e.g. https://<your-client-key>.insights-api.eliq.com/v3. Older integrations sit on https://client-<name>-api.eliq.io/v3. Take the exact value from Eliq rather than assuming a pattern. |
Every scope is a base plus .read for GETs or .write for anything else, the base alone is never accepted, and matching is exact. Most endpoints sit on insights.read / insights.write; some ask for a narrower base, so you also need insights.consents.read / .write, insights.notifications.read and insights.meterreads.read if you use those features. Ask Eliq to attach what your integration needs; a token missing a scope fails with 403, not 401.
Six writes, and the order is not a suggestion, each one references the one before it. Users, locations and meters are idempotent PUTs on your own identifier, so re-running them is safe. Profile, price formula and energy writes are POSTs; re-sending energy for the same intervals updates it.
Contact details are optional, in an API setup Eliq normally does not own end-user contact, so you can provision with nothing but your own id.
PUT /integration/api/v1/users/cust-90412 { "id": "cust-90412", "language_code": "sv-SE" } // → { "id": "cust-90412", "eliq_id": 4412907, "created_at_timestamp": … } // keep eliq_id — the Insights API asks for it
Address and geo drive weather correction, spot-price region and comparison groups. country_code and coordinates are worth getting right on day one.
PUT /integration/api/v1/locations/mp-735999100000041 { "id": "mp-735999100000041", "user_id": "cust-90412", "address": { "street_address": "Kungsportsavenyen 12", "postal_code": "41136", "city": "Göteborg", "country_code": "SE" }, "geo": { "latitude": 57.7005, "longitude": 11.9746 }, "spot_price_region_id": "<region>" // required for market prices }
A new location starts as undefined and must be typed residential or business before profile values will take. Profile keys are what disaggregation and household comparison run on, heating_type_primary, living_area, persons, electric_cars, solar_installed, and for business the business_hours_* family. Push whatever your CIS already knows; let the app collect the rest.
POST /integration/api/v1/locations/mp-735999100000041/profile/type { "type": "residential" } POST /integration/api/v1/locations/mp-735999100000041/profile [ { "key": "living_area", "value": 112 }, { "key": "persons", "value": 3 } ] // keys and their allowed values: GET /v3/locations/{locationId}/profile
model is assigned by Eliq and decides the shape of data you may then send. fuel is elec, gas or district_heating; type is the direction. Give a supply_start_date so the app can bound its date picker.
PUT /integration/api/v1/meters/meter-041-import { "id": "meter-041-import", "location_id": "mp-735999100000041", "model": "<model assigned by Eliq>", "fuel": "elec", "type": "import", "supply_start_date": "2022-01-01" }
Eliq’s representation of the contract: currency, validity window, standing charge and unit rate. Without it, every unit=cost read fails with INVALID_PRICE_FORMULA, so if cost is on your roadmap at all, provision it now rather than retrofitting. A unit-rate element of type spot_price is what marks the location as being on a dynamic tariff. The full element catalogue is in Price formula model.
POST /integration/api/v1/locations/mp-735999100000041/priceformulas { "fuel": "elec", "direction": "import", "source": "client", "currency_code": "SEK", "from": "2026-01-01", "to": "2026-12-31", "elements": [ { "name": "Daily charge", "type": "charge", "charge": { "charge": 1.6, "resolution": "day" } }, { "name": "Energy price", "type": "unit_rate", "unit_rate": { "type": "fixed", "fixed": { "unit_rate": 1.25 } } } ] } // every element type, incl. spot_price and time-of-use: see Price formula model
All energy data is addressed to a specific meter id, as an array of readings. Daily rows carry a local date; high-resolution rows carry a UTC interval_start. Send your highest available resolution: Eliq aggregates downward, never upward, so daily-only data means no hourly chart, ever. Ingestion accepts month, day, hour, 30min, 15min, 10min and 6min, note that 10min is an ingestion resolution only, and not a valid Insights query resolution.
POST /integration/api/v1/meters/meter-041-import/energy/highres/15min [ { "interval_start": "2026-09-08T22:00:00Z", "energy_wh": 104, "is_estimated": false }, { "interval_start": "2026-09-08T22:15:00Z", "energy_wh": 98, "is_estimated": false } ] POST /integration/api/v1/meters/meter-041-import/energy/day [ { "date": "2026-09-09", "energy_wh": 9840, "is_estimated": false } ] // gas and district heating can send energy_m3 · longer periods: POST …/energy/period
Two clocks. High-resolution energy is UTC, to match industry reporting. Daily energy is local time, so a day is never split across a timezone boundary. And everything you later read from the Insights API comes back in the location’s local time. Encode this once in your ingestion layer.
Single PUTs are for onboarding a customer and for trickle updates. For a portfolio backfill or a nightly batch, use the asynchronous Jobs API instead.
curl -F "content=@users.ndjson" \ -H "Authorization: Bearer $TOKEN" \ https://data-management-api.eliq.com/integration/api/v1/jobs/user/upsert # then poll GET /integration/api/v1/jobs/{jobId} // state: queued → processing → completed | failed GET /integration/api/v1/jobs/{jobId}/failures // row_number, error_code, error_description
| Rule | Detail |
|---|---|
| Format | NDJSON, one JSON object per line, \n separated. Sent as multipart/form-data under the field content. |
| Size | 100 MB per file, and 100 KB per line. Split larger loads. |
| Entities | user, location, meter, energy-period, energy-daily, energy-highres-{hour|30min|15min|10min|6min}, homeprofile, location-profile, priceformula. Operations vary: location, meter and priceformula take upsert and delete; user and homeprofile add export; location-profile is export only. Check the reference before assuming a combination exists. |
| Ordering | Jobs are processed in the order received. Rows within one file are not, never put two updates to the same entity in one file. |
| Partial success | Successful rows in a failed job are not rolled back. Always read /failures; don’t infer from state alone. |
This is the answer to “which endpoint do I call first?”, and to most of the questions after it.
No query parameters. It returns an array of the user’s locations, and each entry is a capability manifest: which fuels exist, which direction they run, the finest resolution you may ask for, the date window that actually holds data, and every meter behind it. Call it once at session start, cache the result, and drive your whole UI from it. Do not hardcode a resolution or a date range, read them from here.
[
{
"id": 16644191, 1
"name": "Home",
"ext_ref": "mp-735999100000041", 2
"timezone": "Europe/Stockholm", 3
"address": { "street_address": "Kungsportsavenyen 12",
"postal_code": "41136", "city": "Göteborg",
"country_code": "SE", "lat": 57.7005, "lon": 11.9746 },
"fuels": {
"elec": {
"import": {
"resolution": "15min", 4
"source": "smart_meter",
"sync_status": "ok", 5
"data_from": "2024-03-01T00:00:00", 6
"data_to": "2026-09-09T23:45:00"
},
"export": { /* present only if they export — solar */ }
}
// "gas" / "district_heating" appear only if metered
},
"meters": [ 7
{ "id": "e7c8938fe06a44a7923ed89da281705e",
"fuel_type": "elec", "direction": "import",
"is_sub_meter": false, "user_setup_completed": true,
"name_user": "Main", "supply_start_date": "2022-01-01" }
]
}
]id, the only identifier you use from now on. Every subsequent path is /v3/locations/16644191/…. Persist the user’s last selection so the app reopens where they left it.ext_ref is your own key, echoed back. Join on this to show your own account or metering-point reference in the UI.timezone is not decoration. All Insights timestamps are the location’s local wall clock. Build every from/to in this zone, not the device’s and not UTC.resolution is the finest granularity available for that fuel and direction. Ask for something finer and you get an error; render your resolution switcher from this value. Possible values: 6min, 15min, 30min, hour, day.sync_status is a hint, not a verdict, and it is optional. It appears only for directions whose data syncs on demand from a smart meter, so its absence means nothing. When present, waiting_for_data means a connection exists but nothing has arrived: useful for copy, never a gate on rendering. Use 6 instead.data_from and data_to are present, data exists and this is exactly the window you may query. If either is absent, treat the direction as having no data and show onboarding instead of an empty chart. Clamp your date picker to this range and you will never ask for a period that returns all nulls.meters[] is for per-meter breakdowns. Aggregate charts use the location endpoints; a per-meter series uses GET /v3/meters/{meterId}/energy. is_sub_meter tells you what not to add into a total twice.Ask for the completion percentage. That one number drives whether you show a chart, a nudge, or a questionnaire.
GET /v3/locations/16644191/profile/completion // → { "completion_percentage": 62 } // → 400 with code PROFILE_TYPE_NOT_CONFIGURED ⟵ not a bad request! // it means the location has no profile type yet // then, if needed: GET /v3/locations/16644191/profile/type // { "type": "undefined" | "residential" | "business" } POST /v3/locations/16644191/profile/type // { "type": "residential" } // the questions themselves, with possible values and dependencies: GET /v3/locations/16644191/profile // → { "settings": [ { "key": "heating_type_primary", "data_type": "single_select", // "possible_values": [...], "selected_values": [...], // "tags": ["required_for_euc_to_work"], "group": "properties" } ], // "groups": ["properties","appliances","cooking", …] } // write answers back: POST /v3/locations/16644191/profile // [ { key, value, from } ] PATCH /v3/locations/16644191/profile/{valueId} // { value, from }
Build the questionnaire from the API, not from a hardcoded form.GET /profilereturns each question with itsdata_type,possible_values,limits,dependent_propertiesandgroup. Render generically and you inherit new questions for free. Thetagsfield is the useful one for prioritising:required_for_euc_to_workandrequired_for_sh_to_workmark exactly the questions that unlock disaggregation and household comparison, ask those first.
Older integrations use the legacy equivalents GET /v3/locations/{id}/homeprofile, /homeprofile/completion (returning home_profile_completion) and a JSON-Patch PATCH. They still work. Start new work on /profile.
Consumption, production, import and export take the same core parameters and return the same shape. Learn one, you have all four. Only /consumption also takes include_co2 and cost_type.
Which one? import and export are measured at the grid boundary. consumption and production are what the home actually used and generated. For a household with no solar they coincide; with solar they do not, and the distinction is the whole story. Use whichever directions the fuels manifest actually offered you.
| Parameter | Values | Notes |
|---|---|---|
fuel | elec · gas · district_heating | Defaults to elec. Only ask for fuels present in the manifest. |
unit | energy · cost · m3 | Defaults to energy. cost needs a price formula. m3 is gas and district heating only, and is accepted on /consumption alone, production, import and export take energy or cost. |
resolution | 6min 15min 30min hour day month | Never finer than the manifest’s resolution for that direction. |
from / to | 2026-09-01T00:00:00 | Local wall clock, no offset and no Z. to is exclusive. |
include_incomplete | true · false | Defaults to true. Keep it true for a live “so far today” figure; set false for reporting. |
include_co2 | true · false | Defaults to false. Adds emissions alongside the series. /consumption only. |
cost_type | price_formula · average_cost | Defaults to price_formula. /consumption only. |
| Resolution | Max range | Typical use |
|---|---|---|
6min · 15min · 30min · hour | 93 days | Today, this week, a quarter in detail. Enforced as 3 × 31 days, not three calendar months |
day | 5 years | Month view, year-on-year |
month | 5 years | Annual trends |
You get a bare array of values, positionally aligned to the interval sequence from from to to at the given resolution, not timestamped objects. Index n is the n-th interval. A gap is a null inside the array, so build your x-axis from the request, not the response.
GET /v3/locations/16644191/consumption
?fuel=elec&unit=energy&resolution=hour
&from=2026-09-09T00:00:00&to=2026-09-10T00:00:00
{
"consumption": [412, 380, 366, null, 401, 688, 1204, 980, … ],
"resolution": "hour",
"fuel": "elec",
"unit": "energy",
"from": "2026-09-09T00:00:00",
"to": "2026-09-10T00:00:00"
}
// 24 slots for 24 hours. index 3 = 03:00–04:00 local, no reading.
// The value key matches the endpoint: consumption | production | import | export.const INSIGHTS = 'https://<your-client-key>.insights-api.eliq.com/v3'; // 1. Your backend mints the delegated token. The secret stays there. const { access_token } = await fetch('/api/eliq-token').then(r => r.json()); const auth = { Authorization: `Bearer ${access_token}` }; const get = async (path, params) => { const qs = params ? '?' + new URLSearchParams(params) : ''; const res = await fetch(INSIGHTS + path + qs, { headers: auth }); if (!res.ok) throw Object.assign(new Error(res.statusText), { status: res.status, body: await res.json().catch(() => ({})) }); return res.json(); }; // 2. The manifest. Everything below is derived from it. const locations = await get(`/users/${userId}/locations`); const loc = locations[0]; const elec = loc.fuels?.elec?.import ?? loc.fuels?.elec?.consumption; // 3. The only correct data check: both bounds present. if (!elec?.data_from || !elec?.data_to) { const conn = await get(`/locations/${loc.id}/connection-meta-data`); return renderOnboarding(conn.status); // not_connected | action_required | awaiting_… } // 4. Local wall clock, in the LOCATION's timezone, no offset, no Z. const wallClock = (d, tz) => { const p = new Intl.DateTimeFormat('sv-SE', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(d); return p.replace(' ', 'T'); // 2026-09-09T14:05:00 }; const to = new Date(); // exclusive upper bound const from = new Date(to.getTime() - 7 * 864e5); // 5. Respect the manifest: never ask finer than it offers. const series = await get(`/locations/${loc.id}/consumption`, { fuel: 'elec', unit: 'energy', // 'cost' requires a price formula resolution: 'hour', // ≤ elec.resolution, ≤ 93 days from: wallClock(from, loc.timezone), to: wallClock(to, loc.timezone), }); // 6. Positional array → timestamps you generate yourself. const points = series.consumption.map((wh, i) => ({ t: new Date(from.getTime() + i * 36e5), wh, // null means no reading, not zero }));
null is not zero. Plottingnullas0is the single most common rendering bug in an Eliq integration: a missing hour becomes a dramatic dip and users report it as lost energy. Break the line, or gap the bar.
The rest of the API is where Eliq earns its keep, disaggregation, forecasts, comparison, dynamic pricing. Each one needs something in place first, and each one tells you when it isn’t. Read this column before you build the screen.
| Feature | Endpoint | Needs first | Says “not yet” with |
|---|---|---|---|
| Disaggregation where the energy went | GET /locations/{id}/euc | Profile keys tagged required_for_euc_to_work, plus enough history. The requested period must be 28–366 days | TIMESPAN_TOO_LARGE above 366 days; HOUSE_TYPE_REQUIRED_FOR_EUC, NOT_ENOUGH_HOME_PROFILE_OPTIONS_SET, NOT_ENOUGH_DATA_POINTS; on business locations BUSINESS_TYPE_REQUIRED_FOR_EUC, NOT_ENOUGH_BUSINESS_PROFILE_OPTIONS_SET, EUC_NOT_SUPPORTED_FOR_BUSINESS_TYPE |
| Similar homes comparison & ranking | GET /locations/{id}/similarhomes | Profile keys tagged required_for_sh_to_work | result_status envelope carrying an sh_* action code |
| Forecast next 31 days / 12 months | GET /locations/{id}/forecast | History. resolution is day or month only; max 31 days / 12 months | nulls in the series; INVALID_PRICE_FORMULA for cost |
| Hourly forecast next 48 h | GET /locations/{id}/forecast/hourly | Hourly or finer history. Max span 48 hours | HOURLY_FORECAST_RESULT_CANNOT_BE_NULL_OR_EMPTY |
| Spot prices day-ahead, cheap hours | GET /locations/{id}/marketprice/prices…/marketprice/price-summary | spot_price_region_id set on the location via Data Management. Electricity only | LOCATION_MISSING_SPOT_PRICE_REGION |
| Tariff detail what the bill is made of | GET /locations/{id}/price-formula | A provisioned price formula | Empty array. A unit_rate element of type spot_price = dynamic tariff |
| Peak power capacity tariffs | GET /locations/{id}/peakpower/{resolution} ?peak_power_resolution=… | Sub-hourly data, and two resolutions: the path segment is the period summarised (hour, day, month); the required peak_power_resolution query param is the sampling window of the peak (6min, 15min, 30min, hour). Read values from the power array, import/consumption are deprecated there | nulls in the series. An unrecognised path resolution gives 404; a bad peak_power_resolution gives 400 INVALID_PEAK_POWER_RESOLUTION |
| Advice personalised actions | GET /locations/{id}/advice | Profile + history; advice is generated on Eliq’s schedule | Empty list, a normal state, not an error |
| Energy Coach conversational advisor · experimental | POST /locations/{id}/energy-coach/spotlights…/energy-coach/conversations | Consumption history. Same token and scopes as the rest of Insights. See the Energy Coach quickstart | An empty spotlights array: show the chat on its own. 429 QUOTA_EXCEEDED and 503 UNEXPECTED_AI_ERROR are transient: retry with backoff |
| Notifications the feed | GET /users/{id}/notifications | insights.notifications.read on the token. Page with limit and cursor, cursor is the id of the last notification you saw | Empty list. Handle event.type defensively as an open enum: an unknown type should degrade, not throw |
| Consents GDPR gating | GET /users/consents · PUT /users/consents | A user token, note there is no user id in this path | Empty list. Check is_mandatory and is_updated before rendering data |
| Meter connection onboarding state | GET /locations/{id}/connection-meta-data | Nothing beyond a user token for that location | Five states in status: ok, not_connected, action_required, awaiting_confirmation, awaiting_first_data_sync. A third_party_status may accompany it |
Design the empty states first. Every row above has a legitimate “nothing to show yet” path, and a new customer will hit several of them on day one. Treat each error code as UI copy with a next action, “tell us how you heat your home to see where your energy goes” beats a spinner that never resolves. That single decision is the difference between an integration that looks broken in week one and one that onboards.
Handle all four and almost nothing else will surprise you. Handle one and you will ship a chart that silently shows zero.
null inside a 200 arrayEnergy, temperature, forecast and market-price series. The request was fine; that interval has no reading. Gap the line, never coerce to zero. Treat any value that is not a number as null too.
The manifest’s data_from / data_to, a missing CO₂ figure, an absent suggested limit. Nothing failed, the thing simply doesn’t exist for this location. Test for presence, not truthiness.
Similar homes and budgets wrap their payload in a result_status with an action code (sh_*, budget_awaiting_first_run, budget_not_enough_data). A 200 here does not mean there is a number to draw, read the status first.
codeDisaggregation, tariff breakdown, hourly forecast, market price, cost. The HTTP status says “error” but the code in the body says “not configured for this location”, a product state, not a bug. Switch on code wherever it is present, not on the status.
The trap. Two of these four arrive as HTTP 200. If your client layer only branches on res.ok, you will render empty charts and blame the data pipeline. Branch on the body.Failures raised by an endpoint return the same envelope. code and description are the fields to build on; type, category and message are deprecated and will not appear on newer endpoints. transaction_id is what Eliq support needs, log it wherever it is present.
Two classes of failure are written before the endpoint runs and carry no transaction_id: authorization failures (403 with no code, identified instead by category insufficient_scope) and host or client resolution failures (404 INVALID_REQUEST_HOST for an unknown host, 400 CLIENT_DOES_NOT_EXIST_IN_CATALOG when the token’s client does not exist in that environment). Handle the 403 by status.
{
"code": "MISSING_TO",
"description": "Invalid parameter 'to'. Cannot be null",
"transaction_id": "1ad3a534117d4",
"type": "client_error", // deprecated
"category": "invalid_parameter", // deprecated
"message": "Something went wrong, please try again later…" // deprecated
}| Status | Meaning on this API | What to do |
|---|---|---|
| 200 | Success, including “no data for this period” | Inspect the body before drawing |
| 204 | Success, no content. Used by deletes | Don’t parse a body |
| 400 | Invalid or missing parameter. Also the “profile type not set” signal on /profile/completion | Check code before assuming your request was malformed |
| 401 | Token expired or minted for the wrong audience | Refresh once, then re-authenticate. Don’t loop |
| 403 | The token is missing a scope the endpoint needs | A configuration issue, not a runtime one, ask Eliq for the scope |
| 404 | Not found or not yours. Cross-client access returns 404 ENTITY_NOT_FOUND, never 403. An unknown host is INVALID_REQUEST_HOST | Verify the id came from this user’s manifest, and the base URL |
| 429 | Rate limited. Per-IP and per-client buckets exist | Back off exponentially. Cache the manifest instead of refetching per screen |
| 501 | NOT_IMPLEMENTED, the feature is not enabled for your client, or a legacy disaggregation precondition is unmet. Not transient | Treat as a product state, not an outage. Don’t retry |
| 500 · 502 503 · 504 | Eliq-side. INTERNAL_ERROR with a transaction_id | Retry idempotent GETs; report the transaction_id |
to is exclusive on every time-series endpoint. A full day is T00:00:00 to the next day’s T00:00:00, not T23:59:59.from cannot be in the future. On the energy series endpoints a from later than today is a 400; use the forecast endpoints for what lies ahead.snake_case (allow_historic, include_co2, notification_types) but a few are camelCase (subResolution, includeStandingCharge, includeIncompleteData). Copy from the reference, don’t infer.extref vs extRef. The user lookup takes ?extref=; the location lookup takes ?extRef=. Yes, really./peakpower/{resolution}, unlike everything else.sh_home_profile_insufficent and sh_not_enough_contributers (alongside sh_no_matching_group and sh_no_price_information). They are kept for backwards compatibility: match the exact strings.unit vocabulary shifts. GET /locations/{id}/energy/tariff-breakdown reports its unit as wh while the series endpoints say energy.GET /users/{userId}/locations is the one call every screen wants. Fetch it once per session, not once per component.id ↔ eliq_id mapping for every user and location you provisioned.required_for_euc_to_work are asked in your onboarding.from/to is built in the location’s timezone, with no offset and no Z, and to is exclusive.null renders as a gap, never as zero.code, with a next action for the user.transaction_id is logged on every failure that carries one; 403s, which carry no code, are handled by status./failures rather than trusting state.Stuck on something this page doesn’t cover, a missing scope, an unexpected code, a resolution you expected to have, email hello@eliq.com with the transaction_id.