> ## Documentation Index
> Fetch the complete documentation index at: https://docs.amps.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Envelope

> Canonical envelope for every rejection. Code taxonomy across 400, 401, 403, 404, 409, 410, 413, 415, 422, 429, 500, 502, 503, and 504, plus when to retry and when to fix the body.

Every Amps response uses the same envelope. Successes carry a `data` field, failures carry an `error` field, and both carry `meta` for tracing and pagination. The shape is predictable across every endpoint, every device type, every status code.

## The envelope

A successful response:

```json theme={null}
{
  "success": true,
  "data": { "id": "device_abc123", "state": "..." },
  "meta": {
    "requestId": "req_8a2Bf3kP",
    "environment": "sandbox",
    "timestamp": "2026-06-01T10:30:00.000Z",
    "latencyMs": 12
  }
}
```

A failed response:

```json theme={null}
{
  "success": false,
  "error": {
    "code": "UNSUPPORTED_UNIT",
    "message": "A supplied parameter unit is not supported.",
    "details": {
      "parameter": "power",
      "providedUnit": "percent",
      "supportedUnits": ["kw"]
    }
  },
  "meta": {
    "requestId": "req_8a2Bf3kP",
    "timestamp": "2026-06-01T10:30:00.000Z",
    "path": "/battery/device_abc123",
    "latencyMs": 14
  }
}
```

`code` is a stable, machine-readable identifier. `message` is the fixed API-owned explanation for that code. `details` is an optional object with structured data you can act on; safe route-specific copy is preserved under `details.description`. Errors always carry a `code`. Generic 500-class failures use `INTERNAL_ERROR`.

Recurring `details` fields: `description`, `unsupportedParameters[]`, `conflictingActionIds[]`, `deviceCapabilities`, `requestedExecution`, `supportedExecution`, `reason`, and the parameter-bounds fields (`parameter`, `value`, `min`, `max`, `unit`, `providedUnit`, `supportedUnits`). Each rides along only when it applies to the failure. These let you fix the request in a single round trip. `deviceCapabilities` carries the supported subset (`supportedModes`, `supportedParameters`, or `supportedActions`) in the same vocabulary as the GET response, so you can re-derive a valid request without re-fetching the device.

## Authentication

| Code                       | HTTP | Trigger                                                   |
| -------------------------- | ---- | --------------------------------------------------------- |
| `UNAUTHORIZED`             | 401  | No API key in the request.                                |
| `INVALID_API_KEY`          | 401  | Key is unrecognised, malformed, revoked, or soft-deleted. |
| `EXPIRED_TOKEN`            | 401  | Key has passed its expiry.                                |
| `RATE_LIMIT_EXCEEDED`      | 429  | Key has exceeded its rate ceiling.                        |
| `INSUFFICIENT_PERMISSIONS` | 403  | Key lacks the required permission.                        |
| `LIVE_ACCESS_DISABLED`     | 403  | Live key against a customer not enabled for live.         |

The platform rate-limits at the API-key layer, so rate-limit rejections come back on the 401 path rather than as a separate 429. A rapid burst against one key can surface as a plain `401 UNAUTHORIZED` ("Authentication failed") rather than `RATE_LIMIT_EXCEEDED`. If you are seeing 401s under load with a key you know is valid, back off and retry rather than treating the key as broken.

See [auth and environments](/concepts/auth-model) for context.

## Resource access

| Code               | HTTP | Trigger                                                                     |
| ------------------ | ---- | --------------------------------------------------------------------------- |
| `DEVICE_NOT_FOUND` | 404  | Device does not exist or is not linked to your account in this environment. |

404 rather than 403 to avoid leaking the existence of devices owned by other customers.

## Malformed requests

| Code                   | HTTP | Trigger                                                                                                                                                                                                                                                                                                                                                           |
| ---------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INVALID_REQUEST_BODY` | 400  | A parseable body whose contents fall outside the canonical vocabulary: a `command` or unit the vocabulary does not define, a parameter of the wrong type, or a settings body that is not a key-value map.                                                                                                                                                         |
| `VALIDATION_ERROR`     | 400  | A query parameter is out of bounds (`limit` above 50 or non-numeric, negative `offset`, or an unrecognised `state` or `type` on `GET /actions`); the body carries `details.fields` keyed by parameter. Or a request body that is not valid JSON, caught at the request boundary before the controller, with the specific explanation under `details.description`. |

A 400 means the request never made it to the device, because it was not a request the API could interpret. This is the boundary with a 422. A 400 fires when the input is not even valid canonical input: a `command` the vocabulary does not define, a unit that is not a canonical unit, a parameter of the wrong type. `VALIDATION_ERROR` is the 400 you hit first on a list GET: a query string outside its bounds, such as `limit` above 50 or non-numeric, or a negative `offset`, comes back with the offending fields under `details.fields`. The action-list endpoint `GET /actions` adds `state` and `type` filters to the same bounds check, so an unrecognised `state` or device `type` there is a `VALIDATION_ERROR` too. The same code covers a request body that never parses as JSON, caught at the request boundary before the controller sees it; `details.description` explains that the body is not valid JSON. A 422 fires when the input is well-formed canonical input that this specific device cannot satisfy, and it carries a `deviceCapabilities` snapshot so you can recover. Sending `command: "explode"` is a 400 `INVALID_REQUEST_BODY`; sending `command: "charge"` to a device that does not declare `charge` is a 422 `UNSUPPORTED_MODE`. The first is a coding error, the second is a device fact you can act on.

A malformed `start` value lands here: a `Z` or `+HH:MM` offset, or a string that is neither a plant-local wall-clock ISO 8601 nor a relative duration (`30m`, `1.5h`), comes back as `400 INVALID_REQUEST_BODY` with the explanation under `details.fields["action.start"]`. Times are plant-local wall-clock without an offset. The 422 `START_*` codes above are semantic rejections (in the past, out of the 30-day horizon, in a DST gap); the format and offset checks fire ahead of them at the request boundary.

## Action validation

| Code                                | HTTP | Trigger                                                                                                                                                                                                        |
| ----------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNSUPPORTED_MODE`                  | 422  | A canonical `command` (mode) is not in the device's `commands` map. Body carries `details.deviceCapabilities.supportedModes`.                                                                                  |
| `UNSUPPORTED_PARAMETER`             | 422  | A parameter is not declared for the resolved command. Body carries `details.unsupportedParameters[]` and `deviceCapabilities.supportedParameters`.                                                             |
| `UNSUPPORTED_UNIT`                  | 422  | A parameter unit is not declared as valid for this device. Body carries `details: { parameter, providedUnit, supportedUnits }`.                                                                                |
| `UNSUPPORTED_PARAMETER_COMBINATION` | 422  | Both parameters are declared and in range, but they cap the same thing, so the pair is ambiguous. Body carries `details.conflictingParameters`. `power` and `current` on one command are the case that occurs. |
| `PARAMETER_OUT_OF_RANGE`            | 422  | A parameter value is outside the declared `min`/`max` bounds. Body carries `details: { parameter, value, min, max, unit }`.                                                                                    |
| `EXECUTION_NOT_SUPPORTED`           | 422  | The request shape (`immediate`, `scheduled`, or `windowed`) is not in the mode's `execution` array. Body carries `details: { requestedExecution, supportedExecution }`.                                        |
| `STRATEGY_NOT_SUPPORTED`            | 422  | The requested `onConflict` strategy is not declared by the device. Body carries `details: { requestedStrategy, supportedStrategies }`.                                                                         |
| `DIRECT_ACTION_UNSUPPORTED`         | 422  | The device accepts no imperative push commands (native-scheduler-only).                                                                                                                                        |
| `START_IN_PAST`                     | 422  | The `start` value is in the past.                                                                                                                                                                              |
| `START_OUT_OF_RANGE`                | 422  | The `start` value is more than 30 days in the future.                                                                                                                                                          |
| `START_NONEXISTENT_WALL_CLOCK`      | 422  | The `start` wall-clock falls inside a DST spring-forward gap (the local clock jumps over it).                                                                                                                  |
| `INVALID_TIME_WINDOW`               | 422  | `start` or `end` violates the time-window contract. Body carries `details.reason` naming the specific issue.                                                                                                   |
| `UNSUPPORTED_SETTING`               | 422  | Setting key is not declared for this device.                                                                                                                                                                   |
| `UNSUPPORTED_SETTING_COMBINATION`   | 422  | Two settings in one request set the same thing on this device. Body carries `details.conflictingSettings`. An EV charger's `max_charge_rate` and `max_charge_current` are one rate ceiling in two units.       |
| `SESSIONS_NOT_SUPPORTED`            | 422  | This charger's manufacturer reports no charging session history, so `GET /ev-charger/{deviceId}/sessions` has nothing to serve. Permanent: `sessions` on the device read says so before you call.              |
| `READ_ONLY_SETTING`                 | 422  | Setting key is declared but read-only.                                                                                                                                                                         |
| `INVALID_SETTING_UNIT`              | 422  | Setting value's unit does not match the unit declared for that setting.                                                                                                                                        |
| `INVALID_SETTING_VALUE`             | 422  | Setting value is the wrong type for that setting (e.g. a string where a number is expected).                                                                                                                   |
| `SETTING_OUT_OF_RANGE`              | 422  | Setting value is outside the declared bounds.                                                                                                                                                                  |

`INVALID_TIME_WINDOW` reasons: `end_without_start`, `invalid_end_format`, `end_not_after_start`, `malformed_wall_clock`, `sub_minute_window_not_supported`, `window_must_not_span_midnight`.

## Conflicts

| Code                     | HTTP | Trigger                                                                                                                                                                                                                                                                                                                                                     |
| ------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CONFLICT`               | 409  | A non-terminal action conflicts with the request and the right `onConflict` can resolve it: none was supplied (`no_strategy_supplied`), or `queue_after` has no window to queue behind (`conflicting_action_not_windowed`). Body carries `details.conflictingActionIds[]`, `details.reason`, and `strategies[]` listing the strategy that would resolve it. |
| `CONFLICT_IN_EXECUTION`  | 409  | The conflicting action is already in execution (`acknowledged` and dispatched, completion pending), so no strategy can displace it (`reason: conflicting_action_in_progress`). Body carries `details.conflictingActionIds[]`. Wait for it to complete or fail, then re-submit.                                                                              |
| `SCHEDULER_ACTIVE`       | 409  | Device-side scheduler conflicts with the requested action. Body carries `details.reason` and `details.recoveryStrategies`.                                                                                                                                                                                                                                  |
| `SCHEDULER_FULL`         | 409  | Merged schedule would exceed the OEM's group limit. Body carries `details.existingGroupCount` and `details.maxGroupCount`.                                                                                                                                                                                                                                  |
| `ACTION_NOT_CANCELLABLE` | 409  | Cancel request for an action already in `acknowledged`, `completed`, `failed`, or `cancelled`.                                                                                                                                                                                                                                                              |

See [conflict resolution](/concepts/conflict-resolution) for the full model.

## Device manufacturer errors

A second family of rejections originates from the device manufacturer, not the API. Auth failures, command refusals, device-offline, rate limits, and transport outages all surface with a stable canonical code, a fixed HTTP status, and an API-owned, customer-safe message. The manufacturer's raw text never reaches the response.

| Class          | Example codes                                                                             | Trigger                                                                                                                 |
| -------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| Authentication | `INVALID_CREDENTIALS`, `MFA_REQUIRED`, `ACCOUNT_LOCKED`, `CREDENTIAL_NOT_FOUND`           | The manufacturer rejected the stored linked-account credential. Re-link the device.                                     |
| Device         | `DEVICE_OFFLINE` (410), `DEVICE_NOT_FOUND` (404), `DEVICE_UNAUTHORIZED` (403)             | The device is missing, offline, or not controllable from this account.                                                  |
| Command        | `COMMAND_NOT_SUPPORTED`, `INVALID_PARAMETERS`, `INVALID_OEM_PARAMETERS`                   | The manufacturer refused the command or a parameter, after the API's own validation passed.                             |
| State conflict | `SCHEDULER_ACTIVE` (409), `SCHEDULER_FULL` (409), `VPP_LOCKED` (409)                      | The device is in a state that overrides or blocks the request. Resolve via `onConflict` or clear the controlling state. |
| Transport      | `RATE_LIMITED` (429), `NETWORK_ERROR` (502), `SERVICE_UNAVAILABLE` (503), `TIMEOUT` (504) | Transient. Retry with exponential backoff.                                                                              |

See the [device error codes](/reference/error-codes) reference for every code, its status, its exact API-owned message, the `details` shape, and what to do.

## Runtime and platform

| Code                  | HTTP | Trigger                                                                                                                                                                                                  |
| --------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INTERNAL_ERROR`      | 500  | Unexpected fault. Includes `requestId` for support correlation.                                                                                                                                          |
| `NOT_IMPLEMENTED`     | 501  | The surface is published ahead of its implementation. The schedule routes return `NOT_IMPLEMENTED` until the scheduler ships; the message names the specific surface (listing, retrieval, write, clear). |
| `SERVICE_UNAVAILABLE` | 503  | Transient unavailability. Safe to retry with exponential backoff.                                                                                                                                        |
| `NOT_YET_AVAILABLE`   | 503  | The route is implemented but Amps has not switched it on for live yet, so the request was refused before the manufacturer was contacted. Sandbox serves the same route in full.                          |

503 also covers transient internal unavailability. Standard retry behaviour suffices. `NOT_IMPLEMENTED` is not retryable; the route is gated until the feature lands. `NOT_YET_AVAILABLE` is not retryable either: nothing changes until the route opens, and the same call against sandbox is served in full.

## When to retry

| Class                                            | Retry?                                                                                |
| ------------------------------------------------ | ------------------------------------------------------------------------------------- |
| 5xx (`INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`)    | Yes, with exponential backoff and jitter.                                             |
| 503 (`NOT_YET_AVAILABLE`)                        | No. The route is not switched on for live yet. Build against sandbox instead.         |
| Rate limit (`RATE_LIMIT_EXCEEDED`)               | Yes, after a backoff.                                                                 |
| 400 (`INVALID_REQUEST_BODY`, `VALIDATION_ERROR`) | No. The request is malformed or non-canonical. Fix the body.                          |
| 401 / 403                                        | No. Fix auth or live-access enablement first.                                         |
| 404 (`DEVICE_NOT_FOUND`)                         | No. The link is missing.                                                              |
| 409 (`CONFLICT`, `SCHEDULER_ACTIVE`)             | Resolve via `onConflict` or wait for the conflict to terminate, then re-submit.       |
| 409 (`CONFLICT_IN_EXECUTION`)                    | No strategy helps. Wait for the in-flight action to complete or fail, then re-submit. |
| 422                                              | No. Fix the request body using `details`.                                             |

<Tip>
  Push actions are not retried server-side on OEM rejection. Commands are
  time-sensitive, and running minutes later is usually worse than failing. You
  decide whether to re-submit, and with what.
</Tip>

## Recovery patterns

A 422 is solvable from the response alone. The `details` payload carries the device's current capability snapshot, so the agent or developer can re-derive a valid request without a second GET.

`UNSUPPORTED_MODE` and `UNSUPPORTED_PARAMETER` carry a `deviceCapabilities` snapshot, so you can rebuild the request without a second GET. `EXECUTION_NOT_SUPPORTED` carries both what you sent and what the mode supports, in the same vocabulary, so you pick a valid shape directly. The error response is enough on its own to fix the call.

Fields are never silently discarded. An unknown field returns 422. A known-but-unsupported field returns the corresponding 422. There is no path where a field is accepted, ignored, and you believe it took effect.

`meta.requestId` ties a request to platform logs. Surface it in your error handler when escalating to support.

## Frequently asked questions

### What does a typical error response look like?

A JSON body with `success: false`, an `error` object with `code` and `message` (plus optional `details`), and a `meta` object with `requestId`, `timestamp`, `path`, and `latencyMs`. The shape is identical across every endpoint, device type, and status code. Successes use the same envelope with `success: true` and `data`; their `meta` carries `requestId`, `environment` (`sandbox` or `live`), `timestamp`, and `latencyMs`. The `code` is stable and machine-readable; the `message` is human-readable; `details` is structured data the caller can act on (e.g., `unsupportedParameters[]`, `conflictingActionIds[]`, a `deviceCapabilities` snapshot).

### Which error codes are retryable?

5xx (`INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`) and rate-limit rejections (`RATE_LIMIT_EXCEEDED`) are retryable with a backoff. 400 (`INVALID_REQUEST_BODY`, `VALIDATION_ERROR`) is not retryable: the request is malformed or non-canonical. 401 and 403 are not retryable: fix auth or live-access enablement first, though a 401 under heavy load can be a rate limit, so back off and retry once. 404 (`DEVICE_NOT_FOUND`) is not retryable: the link is missing. 409s are resolvable via `onConflict`. 422s are not retryable as-is: fix the request body using `details`. Push actions are never retried server-side on OEM rejection because commands are time-sensitive.

### How do I distinguish a client error from a server error?

By status code class. 4xx codes name client errors: 400 (malformed or non-canonical body), 401 (auth), 403 (permissions), 404 (resource access), 409 (conflict), 422 (well-formed but device-rejected). 5xx codes name server-side faults: 500 (`INTERNAL_ERROR`), 503 (`SERVICE_UNAVAILABLE`). The body in either class follows the same envelope. When escalating to support, surface `meta.requestId` in your error handler; it ties the request to platform logs.

### Why does the API never silently discard fields?

Because silent discarding hides bugs and breaks the contract. Every field a client supplies is either honoured, validated, or rejected. Sending an unknown field returns 422. Sending a known-but-unsupported parameter returns 422 `UNSUPPORTED_PARAMETER`. Sending a value outside its bounds returns 422 `PARAMETER_OUT_OF_RANGE`. There is no path where the field is accepted, ignored, and the client believes the field took effect. Honest rejections are part of the API design contract.

## Related concepts

<CardGroup cols={2}>
  <Card title="Canonical Actions" icon="zap" href="/concepts/canonical-actions">
    The shape that produces validation errors.
  </Card>

  <Card title="Conflict Resolution" icon="git-merge" href="/concepts/conflict-resolution">
    `CONFLICT`, `SCHEDULER_ACTIVE` deep dive.
  </Card>

  <Card title="Capabilities" icon="list-checks" href="/concepts/capabilities">
    The `deviceCapabilities` snapshot returned in rejections.
  </Card>

  <Card title="Auth and Environments" icon="key" href="/concepts/auth-model">
    Authentication errors in detail.
  </Card>

  <Card title="Device Error Codes" icon="plug" href="/reference/error-codes">
    Every code the device side can raise, with status, message, and retry
    guidance.
  </Card>
</CardGroup>

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
__html: JSON.stringify({
  '@context': 'https://schema.org',
  '@type': 'FAQPage',
  mainEntity: [
    {
      '@type': 'Question',
      name: 'What does a typical error response look like?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: 'A JSON body with success: false, an error object with code and message (plus optional details), and a meta object with requestId, timestamp, path, and latencyMs. The shape is identical across every endpoint, device type, and status code. Successes use the same envelope with success: true and data; their meta carries requestId, environment (sandbox or live), timestamp, and latencyMs. The code is stable and machine-readable; the message is human-readable; details is structured data the caller can act on (e.g., unsupportedParameters[], conflictingActionIds[], a deviceCapabilities snapshot).',
      },
    },
    {
      '@type': 'Question',
      name: 'Which error codes are retryable?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: '5xx (INTERNAL_ERROR, SERVICE_UNAVAILABLE) and rate-limit rejections (RATE_LIMIT_EXCEEDED) are retryable with a backoff. 400 (INVALID_REQUEST_BODY, VALIDATION_ERROR) is not retryable: the request is malformed or non-canonical. 401 and 403 are not retryable: fix auth or live-access enablement first, though a 401 under heavy load can be a rate limit, so back off and retry once. 404 (DEVICE_NOT_FOUND) is not retryable: the link is missing. 409s are resolvable via onConflict. 422s are not retryable as-is: fix the request body using details. Push actions are never retried server-side on OEM rejection because commands are time-sensitive.',
      },
    },
    {
      '@type': 'Question',
      name: 'How do I distinguish a client error from a server error?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: 'By status code class. 4xx codes name client errors: 400 (malformed or non-canonical body), 401 (auth), 403 (permissions), 404 (resource access), 409 (conflict), 422 (well-formed but device-rejected). 5xx codes name server-side faults: 500 (INTERNAL_ERROR), 503 (SERVICE_UNAVAILABLE). The body in either class follows the same envelope. When escalating to support, surface meta.requestId in your error handler; it ties the request to platform logs.',
      },
    },
    {
      '@type': 'Question',
      name: 'Why does the API never silently discard fields?',
      acceptedAnswer: {
        '@type': 'Answer',
        text: 'Because silent discarding hides bugs and breaks the contract. Every field a client supplies is either honoured, validated, or rejected. Sending an unknown field returns 422. Sending a known-but-unsupported parameter returns 422 UNSUPPORTED_PARAMETER. Sending a value outside its bounds returns 422 PARAMETER_OUT_OF_RANGE. There is no path where the field is accepted, ignored, and the client believes the field took effect. Honest rejections are part of the API design contract.',
      },
    },
  ],
}),
}}
/>
