Skip to main content
GET /battery/{deviceId} returns the device’s current state, its capability declaration, and metadata identifying where the data came from. The retrieval path is identical across device types; only the state schema and command set change.

The response envelope

Every response is wrapped: a successful read carries the device under data, alongside a meta block with requestId, environment, timestamp, and latencyMs. A list response carries {items, pagination} under data. The shape never varies.
{
  "success": true,
  "data": {
    "id": "device_abc123",
    "vendor": "FoxESS",
    "sync": { "available": true, "lastPulledAt": "2026-06-01T10:14:23.000Z" },
    "metadata": { "model": "H1-5.0-E", "source": "cache", "cacheType": "normal" },
    "state": {
      "status": "charging",
      "level": 50,
      "capacity": 10.4,
      "chargeRate": 2.4,
      "dischargeLimit": 10,
      "currentMode": "charge"
    },
    "conflictStrategies": ["cancel_and_replace", "queue_after"],
    "commands": {
      "charge": { "parameters": { "power": { "unit": "kw", "min": 0, "max": 5 } }, "execution": ["immediate", "scheduled", "windowed"] },
      "auto.balanced": { "parameters": {}, "execution": ["immediate", "scheduled"] }
    },
    "settings": {
      "discharge_floor": { "value": 10, "unit": "percent", "min": 0, "max": 100 }
    },
    "lastAction": {
      "id": "action_def456",
      "command": "charge",
      "state": "completed",
      "createdAt": "2026-06-01T09:15:00.000Z",
      "updatedAt": "2026-06-01T09:18:00.000Z",
      "errorCode": null,
      "errorMessage": null,
      "links": { "self": "/actions/action_def456" }
    },
    "currentSchedule": null
  },
  "meta": {
    "requestId": "req_8sW2dRtX",
    "environment": "live",
    "timestamp": "2026-06-01T10:14:23.000Z",
    "latencyMs": 32
  }
}
In code, unwrap before reading:
const body = await res.json();
const device = body.data;
console.log(device.state.status);     // "charging"
state is the live snapshot. commands and settings are computed from the device’s declared capabilities at read time and merged with live values. See capabilities for the full surface.

Reading state: what versus what was asked

state.status is what the device is doing right now: a battery’s status is one of charging, discharging, idle, or standby; an HVAC state.active flag answers the same question for a thermostat (heating or cooling). state.currentMode (battery) is the standing command the device is operating under. The two can split cleanly: a charge whose target has already been met reads status: "idle", currentMode: "charge", chargeRate: 0. The command stands, the floor of the requested action is satisfied, no energy is moving. Push a higher target to see it charge again. lastAction and currentSchedule let one read answer “what did Amps last tell this device to do, and what governs it”, so you can render a device card in a single call. Both are summaries with a link, not full copies: follow lastAction.links.self to GET /actions/{actionId} for the complete record (parameters, full result, all timestamps), and the device’s own actions all appear in GET /actions. The summary carries the canonical command, the lifecycle state, createdAt, updatedAt (the most recent state transition), and errorCode/errorMessage (populated on a failed action, null otherwise) — enough to render the card without a second call. lastAction.command is the same canonical verb you sent, and always matches the full record. Reading lastAction against the live state is also how you catch a third-party override: when the device is doing something other than what Amps last commanded, something else moved it. lastAction is null until the device has its first action, and a just-pushed action may take a moment to appear here; if you have just written and need the up-to-date lifecycle state immediately, fetch the action by id at links.self. currentSchedule is the schedule governing the device. It is null for now and arrives with the scheduler: the GET /schedules and GET /{type}/{id}/schedule routes return 501 until then. Following a schedule already works today through the HVAC follow_schedule command.

Three-tier retrieval (live)

In live, reads pass through three tiers in order. Sandbox skips this entirely; see sandbox versus live below.
Tiermetadata.sourceWhen
CachecacheA recent read is available.
LiveliveNo recent read, or expedited request.
FallbackfallbackLive call fails with a transient error.
GET /battery/{id}     (live)
  -> Cache (time-bounded)
    -> miss or expedite
      -> Live OEM API
        -> transient failure -> stored last-known
Cache and live both report sync.available: true. Fallback reports sync.available: false so you know the data is not authoritative for the current request. The metadata.source field names the tier explicitly. In sandbox, none of the above applies. Sandbox devices are simulated, not physical hardware, so reads always carry metadata.source: "projection" and never report cache, live, or fallback. The reported state reflects the commands you have sent, so the next read after a push is consistent with it. See sandbox versus live.

Cache modes (live): normal and expedited

In live, two read tempos exist. They control how close to real time the response sits versus how often the OEM gets called.
ModeFreshnessWhen to use
NormalUp to 15 minutes oldDefault. Covers dashboard reads, periodic checks.
ExpeditedUp to 1 minute oldTriggered by ?expedite=true. Forces near-fresh data.
A normal request prefers a fresher expedited entry when one exists. Both tiers are checked; whichever is freshest returns. An expedited request goes live on miss; it does not fall back to a 15-minute-old normal cache, because the explicit signal was “I want fresh data”. In live, the cache window is time-bounded only. A push completing does not refresh a stale read for free, so polling the device immediately after a write may return the same view you read before. Two ways to handle this in live, depending on which way you want the trade-off:
  • Subscribe to push.completed for the action and react to that, instead of polling the device read.
  • If you need a fresh read in live right now, call with ?expedite=true.
In sandbox these caveats do not apply: the simulation reflects your latest command on the next read. The cache caveats only exist on the live path. See sandbox versus live.

Transient versus permanent errors

OEM errors are classified:
ClassCodesBehaviour
TransientNETWORK_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE, TIMEOUT, UNKNOWN_ERRORFall back to last-known stored state.
PermanentDEVICE_NOT_FOUND, DEVICE_OFFLINEReturn to caller. No fallback.
Permanent errors mean the device is definitively unknown for this request, and stale fallback data would mislead you. Transient errors mean the OEM is briefly unavailable, so last-known state is the best answer Amps can give. metadata.source: 'fallback' plus sync.available: false make the difference visible. If no data exists at any tier (empty cache, failing OEM call, no last-known record), the API returns 404 DEVICE_NOT_FOUND.

What is live versus cached

The state object is the live or cached device state. The commands and settings objects merge capability declarations with live values: a setting’s bounds come from its declared capabilities, its current value comes from the device. Capability declarations themselves take effect on the next read; there is no separate cache invalidation for capability data. You see canonical names only; OEM-native identifiers never appear in the response.

Polling cadence and OEM rate limits (live)

In live, per-OEM rate limits are honoured automatically. A burst of expedited reads against the same device coalesces; the platform enforces a minimum gap, so short read intervals paired with high volumes will not inadvertently flood the OEM. For state changes that require sub-minute reaction, prefer webhooks over polling. push.completed and push.failed events deliver to your registered endpoint with retry and signature verification built in. Polling is the right default for dashboard-style displays, where the visible freshness window is acceptable. In sandbox, no OEM is being called and no rate limits apply. Read as often as your test loop needs.

Per-device-type fields

Each device type exposes its own state schema. The shape is identical across every supported device of the same type; differences in OEM physics (does this battery report a cell-temperature breakdown? does this inverter expose per-string voltages?) surface as nullable fields when the OEM does not populate them.
Device typeKey state fields
Batterystatus (charging / discharging / idle / standby), level, capacity, chargeRate, dischargeLimit, currentMode (the standing command in canonical vocabulary, e.g. charge, auto.balanced)
EV chargerstatus, isConnected, isCharging, currentPower, maxCurrent, powerRateLimit
HVACtemperature, active (currently heating or cooling), heatSetpoint, coolSetpoint, mode (heat / cool / auto / off), holdType (permanent if a manual mode is overriding the device’s schedule, follow_schedule if it is honouring its programmed schedule)
Solar inverterstatus, currentPower, producing, energyTotal
VehiclebatteryLevel, range, plugged, charging, fullyCharged (the battery has reached its configured chargeLimit), batteryCapacity (usable kWh), chargeLimit (configured upper-bound percentage), chargeRate, chargeTimeRemaining (minutes to reach chargeLimit), maxCurrent (amps the vehicle will accept)
Solar inverters and vehicles are read-only. They report state but take no imperative commands. A POST /solar-inverter/{deviceId} or POST /vehicle/{deviceId} returns 422 DIRECT_ACTION_UNSUPPORTED. Use them for monitoring, dashboards, and fleet-wide reads.

Listing devices

GET /{deviceType} returns a paginated list of devices your API key can access in the current environment. Each entry carries its last known state, and the same lastAction + currentSchedule summaries as the single read, so a fleet view can show what every device is doing without a request per device. Filter by userId to scope to one end-user; paginate via limit (1-50, default 10) and offset (default 0). The list view uses cached state; for fresh data on a single device, hit the device-id endpoint with ?expedite=true.

Sandbox versus live

Sandbox and live share the response shape, the canonical vocabulary, auth, and access control. They differ on what sits behind the read.

Sandbox: the simulation reflects your latest command

In sandbox, the canonical actions you push are the device. Push charge and the next read returns status: "charging" with level climbing toward the target. Push idle and the next read returns idle. Push nothing and the device drifts on its baseline (a solar curve at midday, a battery cycling on the household pattern). Reads and pushes against the same sandbox device stay consistent without polling intervals or cache waits: sandbox devices are simulated, so the next read after a push reflects that command. The read carries metadata.source: "projection" to mark the state as simulated rather than from real hardware. Because each read reflects the simulation’s current state, a value still in motion advances between two reads taken moments apart: a charging battery’s level might read 51 then 52. Compare a read against the command you sent, not against an exact earlier number. Windowed commands behave correctly over time. A command carrying both start and end applies across its window, and the simulated state reverts to a hold once end passes: a read inside the window shows it active, a read afterward shows the device idle, holding whatever level or temperature the window left behind. In sandbox state and lastAction always agree: the read reflects the action you just sent. The cache caveats described above are live-only; they do not apply to sandbox reads.

Live: the read reflects whatever the OEM reports

A live GET /battery/{id} reflects whatever the real OEM reports for the device on the other end, which may differ from the last command Amps sent (a homeowner toggled the panel, the OEM denied the write silently, the device is rate-limited). The lastAction summary against the live state lets you see when those diverge. Cache modes, the three-tier retrieval path, and the ?expedite=true query are all properties of the live read. See auth and environments for how the API key prefix routes you between them.

Frequently asked questions

How fresh is the data returned by GET /battery/?

In sandbox, the next read after a push reflects that command right away; there are no cache windows to wait through, and metadata.source is always projection. In live, the default read can be up to 15 minutes old; append ?expedite=true to drop to near-fresh (up to 1 minute old) at the cost of an OEM round trip. The metadata.source field names the tier the live response came from (cache, live, or fallback).

What is the difference between live data and cached data?

These tiers apply on live only. Live data (metadata.source: "live") is fetched from the OEM at request time. Cached data (source: "cache") is served from a previous fetch and may be older than the previous read. Both indicate sync.available: true because the data is authoritative for what the device most recently reported. Fallback data (source: "fallback"), served from the last-known stored state when a fresh OEM call fails with a transient error, indicates sync.available: false so you know the reading is not fresh. Sandbox device reads sit outside this scheme entirely and carry source: "projection" because sandbox devices are simulated, not physical hardware.

How do I detect when a device is offline?

The platform classifies OEM errors as transient or permanent. DEVICE_OFFLINE is permanent and returns to the caller without falling back to stale data; the response has the standard error envelope and a 4xx-class status. Transient errors (NETWORK_ERROR, RATE_LIMITED, SERVICE_UNAVAILABLE, TIMEOUT) fall back to last-known and set sync.available: false. Offline detection is a property of the live read path; in sandbox the simulator is always reachable.

After a push completes in live, why might the next read still look stale?

In live, reads are served from a cache by default and a push completing does not refresh that cache on its own. Either subscribe to push.completed for the action and react to that, or call the read with ?expedite=true when freshness matters more than the OEM rate limit. In sandbox, this caveat does not apply: the next read reflects the latest command.

Why does GET /battery (the list endpoint) not show fresh live data?

Listing devices returns up to 50 entries per page; calling the OEM API for each device on every list request would multiply per-OEM load by the number of devices. The list view in live uses cached state. For fresh data on a single device, fetch the device-id endpoint with ?expedite=true. The list is shaped for dashboards and inventories; the per-device read is shaped for control loops. In sandbox, the list reflects each device’s most recent command alongside its drift baseline, the same as the single-device read.

Capabilities

The commands and settings objects on the response.

Webhooks

State-change notifications without polling.

Auth and Environments

Sandbox versus live, environment routing.

Error Envelope

DEVICE_NOT_FOUND, transient versus permanent.
For a read-then-push walkthrough, see the cookbook: schedule an overnight charge.