PHP2Shell alert: critical PHP flaws CVE-2026-109618 / 109619 — check your servers and get patched fast
HashTech Systems
EV Charging
Part 3 of 10

OCPI Transport: Response Format, Status Codes and Pagination

The OCPI 2.2.1 response envelope, status_code ranges, pagination headers, date_from filtering, routing headers and request IDs, with the usual pitfalls.

HashTech Engineering · October 26, 2026 · 5 min read

Every OCPI module sits on the same transport conventions: one response envelope, one set of status codes, one pagination scheme, and a handful of headers for tracing and hub routing. Get these right once in a shared layer and every module becomes simpler. Get them wrong and the same bug shows up in locations, sessions, CDRs and tariffs.

This is part 3 of the OCPI 2.2.1 series. It assumes you have completed the credentials handshake.

The response envelope

Every response body is wrapped in the same structure:

json
{
  "data": { },
  "status_code": 1000,
  "status_message": "Success",
  "timestamp": "2026-10-26T10:02:17Z"
}
FieldRequiredNotes
dataOptionalThe payload: an object, an array (for list endpoints), or absent for writes that return nothing.
status_codeYesOCPI business status, separate from the HTTP status.
status_messageOptionalHuman-readable text. Never parse it.
timestampYesWhen the response was generated, in UTC.

Requests, by contrast, are not wrapped. A PUT of a location sends the Location object as the body, not { "data": ... }. Wrapping request bodies in an envelope is a mistake that shows up surprisingly often in new implementations.

Status codes

OCPI status codes are grouped by thousands:

CodeMeaning
1000Generic success
2000Generic client error
2001Invalid or missing parameters (e.g. a required field is absent)
2002Not enough information, e.g. an authorization request without the location details the eMSP needs
2003Unknown Location
2004Unknown Token
3000Generic server error
3001Unable to use the client's API
3002Unsupported version
3003No matching endpoints, or expected endpoints missing between parties
4000Generic hub error
4001Unknown receiver (the hub does not know the party in the OCPI-to-* headers)
4002Timeout on a request forwarded by the hub
4003Connection problem (the receiving party is not connected to the hub)

Two layers of status exist: HTTP and OCPI. Authentication failures use HTTP 401 and are the transport's job. Once a request has been understood and processed, the business result is carried in status_code. In the field you will see partners return HTTP 200 with a 2xxx code, HTTP 400 or 404 with a 2xxx code, and occasionally HTTP 200 with no envelope at all. Your client should read both layers and treat any non-1xxx status_code as a failure even when HTTP says 200.

Timestamps and last_updated

All DateTime values are RFC 3339 strings in UTC, like 2026-10-26T10:02:17Z. Send UTC with a Z; parse leniently, because you will receive fractional seconds and occasionally offsets. Never send local times in DateTime fields. Local time does appear in OCPI, but only in specific places such as tariff restrictions and opening hours, which are interpreted in the location's time_zone.

Almost every object has a last_updated field. It drives incremental sync and conflict handling, so set it whenever any field of the object changes, and include it in every PATCH. A PATCH without last_updated is invalid.

Pagination

GET list endpoints on Sender interfaces (locations, sessions, CDRs, tariffs, tokens) are paginated. The client may pass four query parameters:

ParameterMeaning
date_fromOnly objects with last_updated at or after this time (inclusive)
date_toOnly objects with last_updated before this time (exclusive)
offsetIndex of the first object to return, default 0
limitMaximum number of objects to return; the server may cap it lower
http
GET /ocpi/cpo/2.2.1/locations?date_from=2026-10-26T00:00:00Z&offset=0&limit=100 HTTP/1.1
Host: cpo.example.com
Authorization: Token OWU4MGE1OWMtNmEyZi00YjVkLThlMWMtMmYzYTRiNWM2ZDdl
X-Request-ID: 4f6c2d1e-8a3b-4c5d-9e7f-1a2b3c4d5e6f
X-Correlation-ID: 4f6c2d1e-8a3b-4c5d-9e7f-1a2b3c4d5e6f
http
HTTP/1.1 200 OK
Content-Type: application/json
X-Total-Count: 2731
X-Limit: 100
Link: <https://cpo.example.com/ocpi/cpo/2.2.1/locations?date_from=2026-10-26T00:00:00Z&offset=100&limit=100>; rel="next"
  • X-Total-Count is the total number of objects matching the filter.
  • X-Limit is the maximum page size the server actually applies, which may be lower than you asked for.
  • Link with rel="next" is present when there is another page. Follow it exactly as given; do not compute the next offset yourself.

Offset pagination over changing data is inherently unstable: if objects change while you page through, items can shift between pages. Using a fixed date_to (the time you started the sync) and overlapping your next date_from slightly with the previous run makes the sync tolerant of this. Deduplicate by object ID.

Request and correlation IDs

OCPI 2.2 requires two tracing headers on every request:

  • X-Request-ID: unique per HTTP request. A hub forwarding a request generates a new one for the next hop.
  • X-Correlation-ID: identical across all hops of the same logical request, so a CPO, a hub and an eMSP can all find the same exchange in their logs.

Responses echo both. Log them on every request and response; when a partner says "your CDR POST failed at 14:03", the correlation ID turns an hour of log searching into a single query.

Routing headers and hubs

When messages pass through a hub, the hub needs to know who the request is from and where it should go. OCPI 2.2 defines four headers for that:

http
PUT /ocpi/emsp/2.2.1/sessions/DE/CPX/SESS-000123 HTTP/1.1
Host: hub.example.net
Authorization: Token <base64 token issued by the hub>
Content-Type: application/json
X-Request-ID: 7a1b2c3d-0000-4e5f-8a9b-0c1d2e3f4a5b
X-Correlation-ID: 9f8e7d6c-1111-4b2a-9c3d-4e5f6a7b8c9d
OCPI-from-country-code: DE
OCPI-from-party-id: CPX
OCPI-to-country-code: NL
OCPI-to-party-id: EXA

On peer-to-peer connections the routing headers are optional, but sending them costs nothing and makes it easier to move a partner behind a hub later. If you run a platform that hosts several party IDs on one OCPI endpoint, the OCPI-to-* headers are also how you know which of your tenants a request is addressed to.

Client-owned objects and URL structure

Receiver interfaces address objects by the owner's identity, so the URL includes the owner's country_code and party_id before the object ID:

text
{locations_url}/{country_code}/{party_id}/{location_id}
{locations_url}/{country_code}/{party_id}/{location_id}/{evse_uid}
{locations_url}/{country_code}/{party_id}/{location_id}/{evse_uid}/{connector_id}
{sessions_url}/{country_code}/{party_id}/{session_id}
{tokens_url}/{country_code}/{party_id}/{token_uid}

Object IDs are only unique within a party, so always key stored objects on the triple (country_code, party_id, id). A receiver should also check that the country_code/party_id in the URL match the body and belong to the authenticated partner; otherwise one partner can overwrite another's data on a shared endpoint.

Key takeaways

  • Responses are wrapped in data/status_code/status_message/timestamp; requests are not.
  • Check both HTTP status and status_code. Retry 3xxx/4xxx, do not retry 2xxx.
  • Paginate with date_from (inclusive), date_to (exclusive), offset, limit; follow Link: rel="next" exactly, with a safety cap.
  • Always send and log X-Request-ID and X-Correlation-ID; send routing headers whenever a hub could be involved.
  • Key stored objects on country_code + party_id + id.

Next: Part 4, the Locations module, the first data module most integrations bring live.

Let's Talk

Ready to build something that lasts?

Whether it's a charging network, an AI rollout, or a core enterprise system — let's talk about what you're building.