Errors, pagination and versioning

Handle every error with one code path, page through any list with a cursor, and know which API changes can and cannot break your integration.

6 min readEdit this page
On this page

Three conventions hold for every endpoint. Learn them once and you can predict how an endpoint you have never called will behave.

Handle errors

Every error the API returns, whatever its status, has the same body:

json
{
  "error": "forbidden",
  "message": "you do not have permission to do this",
  "requestId": "7f1c0e2a-5b8d-4c1e-9a53-2f0d6f4b1e77"
}
  • error is a stable code, usually camelCase. The refusals to change who holds what are dotted, such as permission_policy.not_grantable or binding.holder_outside_home_project. Switch on it.

  • message is for people and may be reworded at any time. Never parse it.

  • requestId identifies this request in Flowion's logs.

New codes can appear within /v1, so give your switch a fallback that treats an unknown code as a generic error of that HTTP status. Each endpoint's page in the API reference lists the codes it returns today.

A 500 always reads internalError with a fixed message. The cause is logged on Flowion's side, under the request id.

A 503 is one of two codes, and both carry a Retry-After header with the seconds to wait. serviceUnavailable means something Flowion needs did not answer just now; vendorUnavailable means the charger maker's own cloud (Easee or Zaptec) did not. Wait, then send the request again, with the same Idempotency-Key if it had one. Which part failed is in Flowion's logs under the requestId, so quote that to support. A 504 (timedOut) is different: the request may have been carried out, so read before you repeat a write that had no key.

Find out why a request was refused

Every request is checked against the permission policies the caller holds. A refusal comes in one of four forms, and each tells you what to look at next:

ResponseMeansWhat to do
403 forbiddenThe caller is a member, but no policy they hold allows this, or a deny doesAsk for the policy that carries the permission, or remove the deny
404 notFoundThe caller has no reach: not a member of the organization, outside the project, or no longer active (a removed member, a revoked or expired key)Check the id, then the caller's access. A 404 never says which
403 assistantNotEnabledAn assistant reached an organization that hasn't turned the assistant connection onSomeone who can manage the organization's capabilities turns it on
500 internalErrorFlowion could not make the decision safely, for example a policy that cannot be readRetry, then quote the request id. The request is refused, never let through

The message of a 403 is never the reason to switch on. To learn the reason for certain, ask: Explain a decision takes the member, the permission and where, and returns the decision the API made, or would make, with the reason spelled out. It needs identity.policy.explain.

reasonMeans
allowedA statement allows it, and nothing denies it. decidedBy names the statement
deniedA statement denies it, and a deny beats any allow. decidedBy names it
no-allow-foundNo policy the member holds allows it. This also answers a permission that doesn't exist: an unknown name is never allowed
principal-not-activeThe member or key is no longer active, so no policy is read at all
policy-unreadableA policy they hold could not be read, so the answer is a refusal. Without this a deny that could not be read would quietly stop denying. warnings says which
not-a-memberThe person is not a member of the organization. Explain answers 404 for such a person, as for one that doesn't exist, so you will rarely read it here
assistant-not-enabledYou asked about the member's assistant, and the organization hasn't turned it on

See Write your first policy for an explain call from start to finish.

Policy and binding errors

Writing a policy, giving one to someone or taking one away can be refused for one of these reasons. Each is a stable error code, and the message names the statement when a document is at fault:

CodeStatusMeaning
permission_policy.invalid_document422The document is not valid: no statements, more than 50, a bad time window, an empty condition
permission_policy.unknown_permission422A statement names a permission that is not in the catalogue
permission_policy.deny_on_owner_floor422 when writing a policy, 409 when giving oneA deny names a permission only a transfer of ownership can move
permission_policy.limit_exceeded422 when writing a policy, 409 when giving oneThe organization already holds as many policies, or as many bindings, as it may
permission_policy.name_taken409Another policy of the organization, or a provided one, has this name
permission_policy.in_use409A policy that is given to a member, a key or a person who charges, or carried by a pending invitation, can't be deleted. Take it away first
permission_policy.managed_immutable409A policy Flowion provides can't be changed or deleted
permission_policy.stale409An update named an expectedVersion that is no longer the policy's version. Nothing was changed. Read the policy again and retry
permission_policy.would_remove_last_editor409The change would leave nobody able to change who holds what
permission_policy.management_permission_for_non_member409A permission for running an organization can only be held by a member or an API key, never by a person who charges or the public group
permission_policy.not_grantable403The policy allows something the caller doesn't hold here. You can only give what you hold
permission_policy.deny_not_allowed403The policy denies, and the caller doesn't hold identity.policy.deny
binding.driver_policy_requires_access_permission403The policy decides who may start a charge, and the caller holds the permission to change bindings but not the access permission for this place: authorization.access_policy.manage at a project or a location, authorization.access_policy.manage_charger at a charger
binding.holder_outside_home_project422A person who charges can be given a policy only in their own project and mode

Giving a policy someone already holds here, or the Owner's own policy, answers 409 conflict. Everything else a policy request can be refused for is a 403 forbidden or a 404 notFound, as above.

Quote the request id

Every response, success or error, carries an x-request-id header. Quote it when you contact support, and Flowion can find the exact request.

To tie requests to your own logs, send your own x-request-id: up to 128 letters, digits, -, _, . and :. Flowion keeps it and echoes it back. Anything else is replaced with a generated id rather than refused. Browsers can read the header from another origin.

Page through lists

Every list pages with a cursor. Ask for a page with limit, then pass each page's nextCursor back as cursor until it comes back null:

POST/v1/projects/$PROJECT/charging-stationsbash
curl -G "$API/projects/$PROJECT/charging-stations" \
  -d limit=100 \
  -H "Authorization: Bearer $TOKEN"
200Response
{
  "data": [
    {
      "id": "3f2a9b1c-5d6e-4f70-8a9b-0c1d2e3f4a5b",
      "chargePointId": "garage-01"
    }
  ],
  "nextCursor": "AUx0y2Fh3kQAAAAAAAAAAAAAFMGz_w"
}
POST/v1/projects/$PROJECT/charging-stationsbash
curl -G "$API/projects/$PROJECT/charging-stations" \
  -d limit=100 \
  -d cursor=AUx0y2Fh3kQAAAAAAAAAAAAAFMGz_w \
  -H "Authorization: Bearer $TOKEN"
  • limit defaults to 20 and goes up to 100. A larger value is lowered to the maximum, not refused. A few endpoints have their own default and maximum; their reference page says so.

  • cursor is opaque: do not build, parse or store one. It works only with the filters and limit it was issued with. Change either and start from the first page. A cursor used anywhere else answers 400 invalidCursor.

  • nextCursor is null on the last page. A short page does not mean the end, so check nextCursor.

Lists have no page numbers and no totals: page and perPage answer 400 invalidRequest, like any parameter an endpoint does not take. When you need a count, use an endpoint built for it. For chargers, GET /v1/projects/{projectId}/fleet-health returns total, online, offline, faulted and neverConnected in one call.

A few short lists, such as a charger's outlets and connectors, return everything at once with data only. The reference shows which lists take cursor.

Rely on /v1

Every route lives under /v1. Within it, changes only add:

  • Safe, and made without notice: a new response field, a new optional request field, a new endpoint, a new error code, a new value in a response enum, a new variant of a response union.

  • Breaking, and only in a new version: removing or renaming a field, changing a field's type, or accepting less than before.

So read responses tolerantly: ignore fields you do not know, handle codes you do not recognise, and give every switch on a response enum or union a fallback. A value your code has not seen is not an error in the API.

Expect six months' notice

Something that is going away is deprecated first, and stays for at least six months before it is removed. During that time it works exactly as it did. Three signals tell you, so you can pick up whichever your integration already reads:

  • In the reference. The endpoint's page carries a notice with the removal date and a link to its replacement. A deprecated field is marked the same way.

  • In the API document. The operation or field is marked deprecated, with x-deprecated-since, x-sunset (the earliest removal date) and, when there is one, x-replaced-by.

  • On every response. A deprecated endpoint answers with a Deprecation header, a Sunset header carrying the removal date, and, when it has a replacement, a Link header with rel="successor-version". Errors carry them too, so a client that only ever sees a failure still learns of it.

A change that cannot be made additively arrives as /v2, served beside /v1 for the whole notice period, so you move when you are ready and not on the day it ships. Removing a deprecated endpoint or field before the six months are up is refused by Flowion's own release checks.

Pin an SDK version

The SDKs are versioned as one family. Every SDK shares the same MAJOR.MINOR and each has a PATCH of its own, so every 0.3.x describes the same API, and a patch fixes one language without moving the others.

  • They start at 0.x. Until Flowion declares 1.0, a change that is not backwards compatible raises MINOR, so pin to a MINOR and upgrade it deliberately.

  • From 1.0 on, MINOR only adds and MAJOR is the one that can break you.

  • Which number moves is computed from what changed in the API. It is never chosen by hand, so a release that breaks you cannot ship as a patch.

Next: Sending commands.

In the API reference