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:
{
"data": { },
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-10-26T10:02:17Z"
}| Field | Required | Notes |
|---|---|---|
data | Optional | The payload: an object, an array (for list endpoints), or absent for writes that return nothing. |
status_code | Yes | OCPI business status, separate from the HTTP status. |
status_message | Optional | Human-readable text. Never parse it. |
timestamp | Yes | When 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:
| Code | Meaning |
|---|---|
1000 | Generic success |
2000 | Generic client error |
2001 | Invalid or missing parameters (e.g. a required field is absent) |
2002 | Not enough information, e.g. an authorization request without the location details the eMSP needs |
2003 | Unknown Location |
2004 | Unknown Token |
3000 | Generic server error |
3001 | Unable to use the client's API |
3002 | Unsupported version |
3003 | No matching endpoints, or expected endpoints missing between parties |
4000 | Generic hub error |
4001 | Unknown receiver (the hub does not know the party in the OCPI-to-* headers) |
4002 | Timeout on a request forwarded by the hub |
4003 | Connection 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:
| Parameter | Meaning |
|---|---|
date_from | Only objects with last_updated at or after this time (inclusive) |
date_to | Only objects with last_updated before this time (exclusive) |
offset | Index of the first object to return, default 0 |
limit | Maximum number of objects to return; the server may cap it lower |
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-1a2b3c4d5e6fHTTP/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-Countis the total number of objects matching the filter.X-Limitis the maximum page size the server actually applies, which may be lower than you asked for.Linkwithrel="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:
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: EXAOn 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:
{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. Retry3xxx/4xxx, do not retry2xxx. - Paginate with
date_from(inclusive),date_to(exclusive),offset,limit; followLink: rel="next"exactly, with a safety cap. - Always send and log
X-Request-IDandX-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.

