Admin API response and error contract
This file completes the response contract for every endpoint in Endpoint reference. Read the endpoint row for its success body and the tables below for every shared or endpoint-specific non-success body. By default, errors use several compatibility shapes; callers can opt into one universal envelope.
Optional structured error envelope
Send this request header on any endpoint:
X-Error-Format: envelope
The value comparison is trimmed and case-insensitive. With that exact value, every JSON response whose status is 400 or higher is converted to:
ErrorEnvelope = {
error: {
code: string,
message: string,
details: Record<string, unknown>,
requestId: string
}
}
Example conversion of a default validation body:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid request",
"details": {
"fields": {
"name": ["String must contain at least 1 character(s)"]
}
},
"requestId": "integration-42"
}
}
Conversion rules are exact:
validationErrorsbecomesdetails.fields, andcodebecomesVALIDATION_FAILEDeven when the default body also had a domain code.- A default top-level
codeis retained when there are no validation fields. - Other top-level properties except
error,message,validationErrors, andcodemove intodetails; for example report exportlimitbecomesdetails.limit. - A string top-level
errorbecomesmessage. If none exists, the status fallback is used. - Fallback codes are
AUTH_REQUIRED(401),PERMISSION_DENIED(403),RESOURCE_NOT_FOUND(404),CONFLICT(409),VALIDATION_FAILED(422and other unclassified4xx),RATE_LIMITED(429),EXTERNAL_PROVIDER_FAILED(502), andINTERNAL_ERROR(other5xx, including503). requestIdis the exactX-Request-Idresponse header. A caller-supplied request ID is used only when it matches[A-Za-z0-9._:-]{1,128}after trimming; otherwise the server generates a UUID.
This transformation does not change the status or non-body headers. It also applies to idempotent replays according to the replay request's X-Error-Format header. Binary success responses remain binary; their JSON errors are enveloped. The remaining tables show the default bodies; applying the rules above gives the other possible response body for every row.
Response notation
ValidationError = {
validationErrors: Record<string, string[]>,
code?: string
}
CodedError = {
error: string,
code: string,
// a route may add fields explicitly listed below
}
PlainError = { error: string }
error and validation-message text is descriptive, not a stable programmatic discriminator. Branch on code when present. Request validation returns 422 ValidationError; validation of a whole object can produce { "validationErrors": {} } when no individual field is identified. Invalid JSON and unexpected failures return the global 500 PlainError shown below.
All JSON examples are exact body shapes. Text shown as <message> is the returned validation or operation message and is a JSON string, not a literal angle-bracket value.
Request processing order
Requests are processed in this order:
- workspace-token configuration and authentication;
- host policy and token/workspace rate limits;
- current entitlement filtering;
- for every mutation, idempotency validation/claim;
- endpoint scope checks, request validation, and the requested operation.
Consequently, a mutation with a valid but under-scoped token is still required to send Idempotency-Key. A completed 403, 404, 409, or 422 mutation response is replayable for the same key/request.
Authentication, policy, and rate-limit responses
These responses can occur on every registered /v2/admin endpoint before endpoint-specific processing.
| Status | When | Exact body | Relevant headers |
|---|---|---|---|
401 | Missing, duplicate, comma-joined, non-Bearer, malformed, unknown, expired, revoked, or otherwise invalid workspace token | { "error": "Unauthorized", "code": "INVALID_WORKSPACE_TOKEN" } | WWW-Authenticate: Bearer, Cache-Control: no-store |
404 | The token is valid but its workspace host does not have adminApiEnabled | { "error": "Not Found" } | Cache-Control: no-store |
429 | Per-token or per-workspace request bucket is exhausted | { "error": "Too many attempts. Please try again later." } | Retry-After: <seconds> |
503 | Workspace-token authentication is temporarily unavailable | { "error": "Admin API workspace-token configuration is unavailable", "code": "WORKSPACE_API_CONFIG_UNAVAILABLE" } | — |
503 | Host-policy lookup or current entitlement authorization fails | { "error": "Workspace authorization is temporarily unavailable", "code": "WORKSPACE_AUTHORIZATION_UNAVAILABLE" } | — |
Losing apiAccess removes all effective scopes; losing a feature entitlement removes the affected scopes. The resulting endpoint response is the scope response below, not a token refresh flow.
Scope, idempotency, parsing, ownership, and unexpected failures
| Applies to | Status | Exact body | Headers/notes |
|---|---|---|---|
| Every route after authentication | 403 | { "error": "Insufficient workspace-token scope", "code": "INSUFFICIENT_SCOPE" } | Returned when any required scope is absent. |
| Every mutation | 400 | { "error": "Idempotency-Key is required for this operation", "code": "IDEMPOTENCY_KEY_REQUIRED" } | No key supplied. |
| Every mutation | 400 | { "error": "Exactly one Idempotency-Key is required", "code": "INVALID_IDEMPOTENCY_KEY" } | Duplicate or comma-joined header. |
| Every mutation | 400 | { "error": "Idempotency-Key must contain 1 to 255 visible characters", "code": "INVALID_IDEMPOTENCY_KEY" } | Empty, too long, or contains a control character. |
| Every mutation | 409 | { "error": "Idempotency-Key was already used with a different request", "code": "IDEMPOTENCY_CONFLICT" } | Same token/method/path/key, different URL or body. |
| Every mutation | 409 | { "error": "Request is already processing", "code": "IDEMPOTENT_REQUEST_PROCESSING" } | Retry-After: 5. |
| Every mutation | 409 | { "error": "Previous request outcome is unknown; verify its effects before attempting the operation again", "code": "IDEMPOTENCY_OUTCOME_UNKNOWN" } | Prior processing lease expired, connection closed, or request completed with a 5xx. |
| Every mutation | 503 | { "error": "Idempotency storage unavailable", "code": "IDEMPOTENCY_UNAVAILABLE" } | The API cannot check or save the idempotency record. |
| Any endpoint with invalid query/body/path schema | 422 | { "validationErrors": { "<field>": ["<message>"] } } | Multiple fields/messages may be present. Callers cannot supply cpoId; the workspace always comes from the token. |
| Any nested workspace resource miss or cross-workspace ID | 404 | { "error": "Workspace resource not found", "code": "RESOURCE_NOT_FOUND" } | Used consistently unless a table below says otherwise. |
| Any endpoint with an unexpected failure | 500 | { "error": "Something went wrong" } | For mutations, retrying the same key later yields 409 IDEMPOTENCY_OUTCOME_UNKNOWN. |
Resource path IDs must be positive numbers. A non-positive or non-numeric :siteId, :chargerId, :portId, :tariffId, :profileId, :assignmentId, :userId, :rfidId, :accessId, :limitId, or :localUserId returns 422 ValidationError, not 400.
Site and power-management errors
| Endpoints | Status | Exact body |
|---|---|---|
| Site create/update; workspace field validation | 422 | { "validationErrors": { "<field>": ["<message>"] } } |
DELETE /v2/admin/sites/:siteId when the site has dependencies | 409 | { "error": "<message>", "code": "SITE_NOT_EMPTY" } |
GET /v2/admin/sites/geocode invalid normalized search | 400 | { "error": "<message>", "code": "INVALID_GEOCODE_QUERY" } |
| Same, bounded provider queue full | 429 | { "error": "<message>", "code": "GEOCODING_BUSY" }; includes Retry-After when supplied by the geocoder |
| Same, provider timeout/failure or unexpected geocoder error | 502 | { "error": "<message>", "code": "GEOCODING_UNAVAILABLE" }; an unexpected error uses Address search is temporarily unavailable |
PUT /v2/admin/sites/:siteId/power-management invalid topology/domain configuration | 422 | { "error": "<message>", "code": "INVALID_POWER_MANAGEMENT_CONFIGURATION" } |
| Same, OCPP refusal/timeout, race, or reconciliation conflict | 409 | { "error": "<message>", "code": "POWER_MANAGEMENT_CONFLICT" } |
Charger errors
mapChargerErrors() is used by charger creation/update, credentials, connector settings, public listing, QR payload resolution, configuration mutation/reload, firmware start, all four basic commands, and composite-schedule request.
| Endpoints | Status | Exact body |
|---|---|---|
| Any operation above with a domain validation failure | 422 | { "validationErrors": { "<field>": ["<message>"] } } |
| Any operation above with a state, uniqueness, offline/OCPP, billing, tagless, connector, or readiness conflict | 409 | { "error": "<message>", "code": "CHARGER_CONFLICT" } |
GET /chargers/:chargerId/sessions, invalid defaulted/explicit time range | 422 | { "validationErrors": { "from": ["from must be before or equal to to"] } } or { "validationErrors": { "from": ["Session range cannot exceed 90 days"] } } |
Plain read operations return only the shared 401/403/404/429/500/503 responses plus 422 query/path validation. Charger deletion can return the shared 500 PlainError if deletion fails without a more specific error response.
Report errors
| Endpoints | Status | Exact body |
|---|---|---|
Data/export range where from >= to | 422 | { "validationErrors": { "range": ["from must be earlier than to"] } } |
| Data/export range over 90 days | 422 | { "validationErrors": { "range": ["Report ranges cannot exceed 90 days"] } } |
| Data/measurements invalid opaque cursor | 422 | { "validationErrors": { "cursor": ["<message>"] } } |
GET /reports/export, result exceeds 2,000 rows | 422 | { "error": "<message>", "code": "REPORT_EXPORT_ROW_LIMIT", "limit": 2000 } |
POST /reports/send, no saved schedule | 409 | { "error": "A workspace report schedule must be saved before sending", "code": "REPORT_SCHEDULE_REQUIRED" } |
| Same, mailer rejects/fails | 502 | { "error": "Report delivery failed", "code": "REPORT_DELIVERY_FAILED" } |
| Schedule/measurements owned resource absent | 404 | shared RESOURCE_NOT_FOUND body |
The successful export is binary; all export errors remain JSON.
Tariff errors
| Endpoints | Status | Exact body |
|---|---|---|
| Create/update/components/market-price/assignment domain validation | 422 | { "validationErrors": { "<field>": ["<message>"] } } |
| Tariff delete or assignment state/dependency/uniqueness conflict | 409 | { "error": "<message>", "code": "TARIFF_CONFLICT" } |
| Tariff, bidding zone, charger, connector, tariff assignment, or other nested ownership miss | 404 | shared RESOURCE_NOT_FOUND body |
Charging-profile errors
| Endpoints | Status | Exact body |
|---|---|---|
| Create/update/schedule/assignment domain validation | 422 | { "validationErrors": { "<field>": ["<message>"] } } |
| Profile is referenced by a protected usage | 409 | { "error": "<message>", "code": "CHARGING_PROFILE_IN_USE" } |
| OCPP synchronization, assignment, concurrency, or other profile conflict | 409 | { "error": "<message>", "code": "CHARGING_PROFILE_CONFLICT" } |
| Profile, charger, connector, assignment, or other nested ownership miss | 404 | shared RESOURCE_NOT_FOUND body |
User-domain errors
User validation errors that identify a field use ValidationError plus code; other user errors use CodedError. All missing-resource cases use the shared non-enumerating RESOURCE_NOT_FOUND response.
| Status | Code(s) | Body |
|---|---|---|
400 | INVALID_USER_CURSOR | { "validationErrors": { "cursor": ["The user-list cursor is invalid for the selected status"] }, "code": "INVALID_USER_CURSOR" } |
409 | USER_ALREADY_EXISTS, CUSTOMER_CONTRACT_MISSING, INVITATION_EMAIL_IN_USE, RFID_ALREADY_EXISTS, ACCESS_ALREADY_EXISTS, ACCESS_ALL_ALREADY_EXISTS, LIMIT_ALREADY_EXISTS, LOCAL_USER_ALREADY_EXISTS, PROTECTED_WORKSPACE_USER, TAGLESS_USER_PROTECTED, USER_RELATED_DATA | { "error": "<message>", "code": "<listed code>" } |
422 | INVALID_EMAIL, INVALID_RFID, INVALID_LIMIT_TYPE, INVALID_LIMIT_VALUE | { "validationErrors": { "<field>": ["<message>"] }, "code": "<listed code>" } |
422 | NO_CHARGERS_AVAILABLE | { "error": "No chargers are available", "code": "NO_CHARGERS_AVAILABLE" } |
429 | INVITATION_RESEND_THROTTLED | { "error": "Invitation resend is temporarily unavailable", "code": "INVITATION_RESEND_THROTTLED" }; includes Retry-After |
503 | USER_CURSOR_UNAVAILABLE | { "error": "User pagination is temporarily unavailable", "code": "USER_CURSOR_UNAVAILABLE" } |
404 | RESOURCE_NOT_FOUND | { "error": "Workspace resource not found", "code": "RESOURCE_NOT_FOUND" } |
POST /users/invitations can also return the shared 403 INSUFFICIENT_SCOPE after body parsing if users:access is missing, or if an RFID was supplied and users:rfid is missing. Local-user endpoints list every required scope in Endpoint reference.
The user error codes apply as follows:
| Endpoint family | Non-404 user-domain codes in addition to shared responses |
|---|---|
GET /users | INVALID_USER_CURSOR (400), USER_CURSOR_UNAVAILABLE (503) |
POST /users | USER_ALREADY_EXISTS, TAGLESS_USER_PROTECTED (409) |
PATCH /users/:userId | TAGLESS_USER_PROTECTED (409) |
DELETE /users/:userId | TAGLESS_USER_PROTECTED, PROTECTED_WORKSPACE_USER, USER_RELATED_DATA (409) |
| User detail, vehicles, RFID/access/limit reads | no non-404 domain code |
| RFID create | CUSTOMER_CONTRACT_MISSING, RFID_ALREADY_EXISTS, TAGLESS_USER_PROTECTED (409); INVALID_RFID (422) |
| RFID update/delete | TAGLESS_USER_PROTECTED (409) |
| Access replacement | TAGLESS_USER_PROTECTED (409); NO_CHARGERS_AVAILABLE (422) |
| Access revoke | TAGLESS_USER_PROTECTED (409) |
| Limit create/update | TAGLESS_USER_PROTECTED, LIMIT_ALREADY_EXISTS (409); INVALID_LIMIT_TYPE, INVALID_LIMIT_VALUE (422) |
| Limit delete | TAGLESS_USER_PROTECTED (409) |
| Workspace invitation create | CUSTOMER_CONTRACT_MISSING, INVITATION_EMAIL_IN_USE, RFID_ALREADY_EXISTS, TAGLESS_USER_PROTECTED (409); INVALID_EMAIL/INVALID_RFID (422); INVITATION_RESEND_THROTTLED (429) when delivery cooldown applies |
| Workspace invitation resend | INVALID_EMAIL (422), INVITATION_RESEND_THROTTLED (429) |
| Workspace invitation cancel | INVALID_EMAIL (422) |
| Charger local-user create for existing customer | LOCAL_USER_ALREADY_EXISTS, TAGLESS_USER_PROTECTED (409) |
| Charger local-user invitation | LOCAL_USER_ALREADY_EXISTS, INVITATION_EMAIL_IN_USE, TAGLESS_USER_PROTECTED (409); INVALID_EMAIL (422); INVITATION_RESEND_THROTTLED (429) when delivery cooldown applies |
| Local-user invitation cancel | INVALID_EMAIL (422) |
| Local-user update/delete | TAGLESS_USER_PROTECTED (409) |
All ownership and missing-resource outcomes in those families, including an unregistered email on POST /users, use the shared 404 RESOURCE_NOT_FOUND body.
No-body and replay responses
A successful 204 response has no body. This applies to every documented delete that lists 204 empty. A completed idempotent replay sets Idempotency-Replayed: true and uses the stored status/body; for 204, the HTTP response remains bodyless. The API never returns a JSON success object for a documented 204 operation.