From zero to hero

From zero to hero

From credentials to a rendered consumption chart: the full integration path in one article.

From zero to hero

Six phases, in order, with the exact call to make at each step and the exact field that tells you what to do next.

  • Written for: Engineers integrating Eliq into a backend and an end-user app
  • You need: client_id + client_secret and your API host from Eliq
  • Two APIs: Data Management to write, Insights to read
  • First app call: GET /v3/users/{userId}/locations

Phase 00: Orientation. Two APIs, three entities, one unit of everything

Almost every integration question (which endpoint first? why is this empty?) resolves once these two pictures are in your head.

You write to one API and read from another

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.

YOUR SIDE Your platform CIS · MDM · billing Your backend holds the secret · mints tokens Your app web · iOS · Android user token ELIQ Data Management API aud: data-management-api Eliq analytics engine disaggregation · forecast · comparison Insights API aud: insights-api PUT users · locations · meters POST energy · price formulas GET locations · consumption GET profile · euc · forecast POST /oauth/token
Your backend is on both paths. It pushes data in, and it mints the short-lived user token your app carries, the client secret never leaves it.

A user has locations, a location has meters

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?”

User id · ext_ref Location id · ext_ref · timezone Meter fuel · direction 0 … n 0 … n Profile house · heating · people Price formula the contract, for cost Energy data month · day · hour · 30 · 15 · 10 · 6 min every insight endpoint hangs off this id /v3/locations/{locationId}/…
Profile and price formula belong to the location; energy data belongs to the meter. Cost insights need a price formula; disaggregation needs a profile. Missing either is the usual reason a feature returns nothing.

The one thing that trips everyone: two ID systems

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.

Same two identifiers, mirrored naming
APIid meansYour own key isYou 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 returns eliq_id alongside your id. That eliq_id is 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 with GET /v3/users?extref={yours} or GET /v3/locations?extRef={yours}.

Phase 01: Access. Get a token, two kinds, both minted in your backend

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

App token, for your own server-to-server work

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.

App token
# 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 }

Delegated user token, for your app

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.

Delegated user token
# 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-token for the signed-in user, that mints a delegated token and returns only that. Every call below then travels as Authorization: Bearer <access_token>.

Your host is part of your identity

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.

Base URLs to configure
PurposeBase URL
Tokenhttps://auth-api.eliq.com
Data Managementhttps://data-management-api.eliq.com/integration/api/v1/
InsightsThe 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.

Phase 02: Platform to platform. Push your data in dependency order

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.

User PUT users/{id} Location needs user_id Profile type, then values Meter needs location_id Price formula unlocks cost Energy per meter DASHED = OPTIONAL, BUT GATES A FEATURE skip the profile and disaggregation stays empty skip the formula and unit=cost fails
The two dashed steps are the ones integrations most often defer, and the two that later produce “the app shows nothing” tickets.

1. Create the user

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.

Data Management
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

2. Create the location, pointing at that user

Address and geo drive weather correction, spot-price region and comparison groups. country_code and coordinates are worth getting right on day one.

Data Management
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
}

3. Set the profile type, then the profile values

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.

Data Management
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

4. Create the meter on that location

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.

Data Management
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"
}

5. Add the price formula

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.

Data Management
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

6. Upload energy data

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.

Data Management
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.

Backfill and nightly loads: use Jobs

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.

Bulk upsert
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
What to know before your first job
RuleDetail
FormatNDJSON, one JSON object per line, \n separated. Sent as multipart/form-data under the field content.
Size100 MB per file, and 100 KB per line. Split larger loads.
Entitiesuser, 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.
OrderingJobs are processed in the order received. Rows within one file are not, never put two updates to the same entity in one file.
Partial successSuccessful rows in a failed job are not rolled back. Always read /failures; don’t infer from state alone.

Phase 03: In the app. One call tells you everything you need to render

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.

Response, annotated
[
  {
    "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" }
    ]
  }
]
  1. 1id, 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.
  2. 2ext_ref is your own key, echoed back. Join on this to show your own account or metering-point reference in the UI.
  3. 3timezone 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.
  4. 4resolution 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.
  5. 5sync_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.
  6. 6This is the real test for “is there data?” If both 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.
  7. 7meters[] 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.

“How do I know if there is a home profile?”

Ask for the completion percentage. That one number drives whether you show a chart, a nudge, or a questionnaire.

Profile state in three calls
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 /profile returns each question with its data_type, possible_values, limits, dependent_properties and group. Render generically and you inherit new questions for free. The tags field is the useful one for prioritising: required_for_euc_to_work and required_for_sh_to_work mark 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.

The whole session, as a decision

Your backend mints a user token sub = user, aud = insights-api GET /v3/users/{userId}/locations pick a location · cache the manifest data_from AND data_to both present? no GET …/connection-meta-data not_connected · action_required · awaiting_* Render onboarding, not a chart connect meter · or “data on its way” yes GET …/consumption clamp from/to to the data window Chart it then layer profile-gated features GET …/profile/completion in parallel · gates EUC & comparison
Two calls decide the entire first screen. The profile check runs alongside, not before, a location with data but no profile still charts fine; it just can’t disaggregate yet.

Phase 04: The data. Four endpoints, one query contract

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.

Query parameters
ParameterValuesNotes
fuelelec · gas · district_heatingDefaults to elec. Only ask for fuels present in the manifest.
unitenergy · cost · m3Defaults 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.
resolution6min 15min 30min hour day monthNever finer than the manifest’s resolution for that direction.
from / to2026-09-01T00:00:00Local wall clock, no offset and no Z. to is exclusive.
include_incompletetrue · falseDefaults to true. Keep it true for a live “so far today” figure; set false for reporting.
include_co2true · falseDefaults to false. Adds emissions alongside the series. /consumption only.
cost_typeprice_formula · average_costDefaults to price_formula. /consumption only.
Maximum span per request, split anything longer
ResolutionMax rangeTypical use
6min · 15min · 30min · hour93 daysToday, this week, a quarter in detail. Enforced as 3 × 31 days, not three calendar months
day5 yearsMonth view, year-on-year
month5 yearsAnnual trends

Reading the response

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.

Request and 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.

End to end, in one file

JavaScript · token → locations → chart data
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. Plotting null as 0 is 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.

Phase 05: Beyond the chart. Every extra feature has a precondition

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 endpoints and their preconditions
FeatureEndpointNeeds firstSays “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.

Reference. The API says “no data” in four different ways

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 array

Energy, 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.

An absent optional field

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.

A status envelope on a 200

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.

A non-2xx carrying a code

Disaggregation, 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.

Reference. Errors, status codes and the gotchas we know about

The error 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.

Error envelope
{
  "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
}
What each status actually means here
StatusMeaning on this APIWhat to do
200Success, including “no data for this period”Inspect the body before drawing
204Success, no content. Used by deletesDon’t parse a body
400Invalid or missing parameter. Also the “profile type not set” signal on /profile/completionCheck code before assuming your request was malformed
401Token expired or minted for the wrong audienceRefresh once, then re-authenticate. Don’t loop
403The token is missing a scope the endpoint needsA configuration issue, not a runtime one, ask Eliq for the scope
404Not found or not yours. Cross-client access returns 404 ENTITY_NOT_FOUND, never 403. An unknown host is INVALID_REQUEST_HOSTVerify the id came from this user’s manifest, and the base URL
429Rate limited. Per-IP and per-client buckets existBack off exponentially. Cache the manifest instead of refetching per screen
501NOT_IMPLEMENTED, the feature is not enabled for your client, or a legacy disaggregation precondition is unmet. Not transientTreat as a product state, not an outage. Don’t retry
500 · 502
503 · 504
Eliq-side. INTERNAL_ERROR with a transaction_idRetry idempotent GETs; report the transaction_id

Gotchas worth reading before you debug

  • Timezones split by API. Insights and Data Management daily energy use the location’s local wall clock with no offset. High-resolution ingestion uses UTC. Two conversions, one codebase, isolate them.
  • 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.
  • Parameter casing is not uniform. Mostly 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.
  • Resolution is sometimes a path segment. /peakpower/{resolution}, unlike everything else.
  • Some codes are misspelled, permanently. Similar homes returns 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.
  • Aggregation is one-way. Eliq rolls fine data up, never down. If you only ever send daily rows, an hourly view is impossible later without a backfill.
  • Cache the manifest. GET /users/{userId}/locations is the one call every screen wants. Fetch it once per session, not once per component.

Before you go live. Integration checklist

  • Client secret lives only in your backend; the app receives a delegated user token from your own endpoint.
  • Base URLs are configuration, one set per environment, UAT and production hosts are different.
  • You persisted the id ↔ eliq_id mapping for every user and location you provisioned.
  • Location profile type is set, and the questions tagged required_for_euc_to_work are asked in your onboarding.
  • Price formulas are provisioned if you show cost anywhere.
  • You send the highest resolution you have, not the resolution you currently display.
  • Every from/to is built in the location’s timezone, with no offset and no Z, and to is exclusive.
  • Resolution and date range are clamped to the manifest, never hardcoded.
  • null renders as a gap, never as zero.
  • All four “no data” forms are handled, including the two that arrive as HTTP 200.
  • Each feature has a designed empty state keyed on its error 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.
  • Token refresh happens once on 401, with no retry loop.
  • Batch loads go through Jobs, and you read /failures rather than trusting state.

Where to go next

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.

‍

‍

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Updated on: 
Oct 17, 2026