If you have ever stared at the OCPI 2.2.1 spec trying to reverse-engineer what an actual request looks like, this article is the shortcut. Below are copy-paste-ready examples for Locations, Sessions, and CDRs, the three modules a first integration has to get working. Each one gives you the HTTP method, the endpoint path, the headers that matter, and a fully populated JSON body.

Reading a payload is one thing; watching it move between a CPO and an eMSP is another. Once you have the shapes below in your head, try the interactive OCPI Simulator to replay the credentials handshake and push these same objects across a live connection. The handshake is the part that is hardest to picture from a static example, and the credentials and versions walkthrough covers what the two parties swap before any request below is even legal.
The parts every OCPI request shares
Before the module-specific bodies, three things are constant across almost every OCPI call. Each of them fails in a way that looks like a body problem when it is not.
The endpoint is versioned and role-scoped. OCPI URLs are not fixed; you discover them from the other party’s versions endpoint during the handshake. They do follow a predictable shape:
https://cpo.example.com/ocpi/cpo/2.2.1/locations
https://emsp.example.com/ocpi/emsp/2.2.1/sessions
The cpo or emsp segment tells you which role hosts that copy of the module. Locations are hosted by the CPO and pulled or pushed to the eMSP; Sessions and CDRs are hosted by the eMSP and pushed to by the CPO.
The Authorization header is mandatory. After credentials are exchanged, every functional request carries:
Authorization: Token <base64-token>
The word Token is literal (it is the scheme name, not the value), and in OCPI 2.2 and later the token itself is base64-encoded. Skip the encoding and the server rejects the call with a 401 before it reads a single byte of your body.
Responses are wrapped in an envelope. You never get a bare object back. OCPI wraps every response body:
{
"data": { },
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-12T09:30:00Z"
}
data holds an object on single-object endpoints and an array on list endpoints. status_code is the OCPI-level verdict: 1000 is success, the 2xxx range is a client-side error, and 3xxx is a server-side one.
So check status_code, not just the HTTP status. A 200 OK carrying status_code: 2001 means your request arrived intact and was refused anyway. The OCPI overview explains where this two-layer design comes from, and the error response reference (coming soon) lists what each range actually signals.
Locations: a GET request and its response
Locations is the map: where the stations are, what connectors they have, whether they are live. The CPO hosts it and the eMSP consumes it, either by polling on a schedule or by receiving pushes (the tradeoff is laid out in pull versus push patterns (coming soon)). A paginated pull looks like this:
GET /ocpi/cpo/2.2.1/locations?date_from=2026-07-01T00:00:00Z&limit=50
Host: cpo.example.com
Authorization: Token bXktc2VjcmV0LXRva2Vu
Accept: application/json
The date_from filter and limit are how you page through a large catalog without re-pulling everything. The response wraps an array of Location objects; here is one, trimmed to the load-bearing fields:
{
"country_code": "US",
"party_id": "EVC",
"id": "LOC_10021",
"publish": true,
"name": "Downtown Garage - Level 3",
"address": "425 Market Street",
"city": "San Francisco",
"postal_code": "94105",
"state": "CA",
"country": "USA",
"coordinates": {
"latitude": "37.79130",
"longitude": "-122.39690"
},
"evses": [
{
"uid": "EVSE_10021_1",
"evse_id": "US*EVC*E10021001",
"status": "AVAILABLE",
"connectors": [
{
"id": "1",
"standard": "IEC_62196_T1_COMBO",
"format": "CABLE",
"power_type": "DC",
"max_voltage": 920,
"max_amperage": 500,
"max_electric_power": 350000,
"tariff_ids": ["TARIFF_DC_PEAK"],
"last_updated": "2026-07-11T18:04:00Z"
}
],
"last_updated": "2026-07-11T18:04:00Z"
}
],
"time_zone": "America/Los_Angeles",
"last_updated": "2026-07-11T18:04:00Z"
}
A few things worth internalizing: evse_id (the roaming-visible identifier) is different from uid (the internal, opaque key you use in URLs). status lives on the EVSE, not the connector. And max_electric_power is in watts, so a 350 kW connector reads 350000, not 350. The full field map, including the many optional facility and image fields, is in the Locations module deep-dive.
Sessions: pushing a live session with PUT and PATCH
A Session is a living object. The CPO creates it on the eMSP with a full PUT the moment charging starts, then streams updates with PATCH. Here is the initial push:
PUT /ocpi/emsp/2.2.1/sessions/US/EVC/SESSION_45501
Host: emsp.example.com
Authorization: Token bXktc2VjcmV0LXRva2Vu
Content-Type: application/json
{
"country_code": "US",
"party_id": "EVC",
"id": "SESSION_45501",
"start_date_time": "2026-07-12T09:15:00Z",
"kwh": 0,
"cdr_token": {
"country_code": "DE",
"party_id": "IONE",
"uid": "DE-IONE-C1234567890-1",
"type": "RFID",
"contract_id": "DE-IONE-C1234567890-1"
},
"auth_method": "AUTH_REQUEST",
"location_id": "LOC_10021",
"evse_uid": "EVSE_10021_1",
"connector_id": "1",
"currency": "USD",
"status": "ACTIVE",
"last_updated": "2026-07-12T09:15:00Z"
}
Note that the URL carries the country_code, party_id, and session id as path segments; that trio is what uniquely addresses the object. Once the session is live, the CPO does not re-send the whole thing. It sends only what changed:
PATCH /ocpi/emsp/2.2.1/sessions/US/EVC/SESSION_45501
Host: emsp.example.com
Authorization: Token bXktc2VjcmV0LXRva2Vu
Content-Type: application/json
{
"kwh": 18.4,
"status": "ACTIVE",
"last_updated": "2026-07-12T09:34:12Z"
}
That last_updated field is doing real work. If two PATCH requests arrive out of order, which a busy connection makes entirely possible, the eMSP compares timestamps and discards the older one, so the session never appears to “go backwards.” Building the receiver correctly means treating last_updated as the tiebreaker, a point the Sessions module walkthrough covers in depth. The running kwh and total_cost in a Session are estimates; the authoritative billing number arrives later, in the CDR.
CDRs: posting the final billing record
When a session ends, the CPO posts a Charge Detail Record, the immutable billable summary. Unlike Sessions, a CDR is created once with a POST and never modified:
POST /ocpi/emsp/2.2.1/cdrs
Host: emsp.example.com
Authorization: Token bXktc2VjcmV0LXRva2Vu
Content-Type: application/json
{
"country_code": "US",
"party_id": "EVC",
"id": "CDR_88120",
"start_date_time": "2026-07-12T09:15:00Z",
"end_date_time": "2026-07-12T09:42:33Z",
"session_id": "SESSION_45501",
"cdr_token": {
"country_code": "DE",
"party_id": "IONE",
"uid": "DE-IONE-C1234567890-1",
"type": "RFID",
"contract_id": "DE-IONE-C1234567890-1"
},
"auth_method": "AUTH_REQUEST",
"cdr_location": {
"id": "LOC_10021",
"evse_uid": "EVSE_10021_1",
"evse_id": "US*EVC*E10021001",
"connector_id": "1",
"connector_standard": "IEC_62196_T1_COMBO",
"connector_format": "CABLE",
"connector_power_type": "DC",
"coordinates": { "latitude": "37.79130", "longitude": "-122.39690" }
},
"currency": "USD",
"tariffs": [ { } ],
"charging_periods": [
{
"start_date_time": "2026-07-12T09:15:00Z",
"dimensions": [ { "type": "ENERGY", "volume": 42.7 } ]
}
],
"total_cost": { "excl_vat": 18.90, "incl_vat": 20.60 },
"total_energy": 42.7,
"total_time": 0.459,
"last_updated": "2026-07-12T09:42:40Z"
}
The CDR snapshots everything it needs to stand alone: the location, the tariff that applied, and each charging period. That is deliberate: a CDR must remain valid and auditable even after the live Location or Tariff it was built from has changed. And because it is immutable, corrections are not edits. To fix a CDR you post a credit CDR that references the original, a pattern explained fully in the CDRs module guide. Note that cdr_location is a compact snapshot with its own field set rather than a full Location object, so code that deserializes both into the same class will break on the first CDR it sees.
Why these break, and how to see it live
Reading three well-formed requests makes OCPI look easy. Everything above is correct, and that is precisely what hides the ways it goes wrong:
- Missing or unencoded
Authorizationtoken. You get a401, or a400when the scheme word itself is dropped. - Path/body mismatch, where the
idin the URL disagrees with theidin the body on a Session PUT. - Wrong role in the path, such as pushing a Session to
/cpo/when Sessions are hosted at/emsp/. - Ignoring
status_code: a200 OKwithstatus_code: 2001is logged as a success and the object silently never lands. - Applying a stale PATCH, because the receiving side never compares
last_updated.
None of these surface until you send a real request at a real counterparty. That is what the OCPI Simulator is for: paste these bodies in, watch the envelope come back, then break a header on purpose and see which status_code you get.
One thing worth carrying away from all this JSON: the payloads are the easy half. Every object above is a snapshot of state that was true at one last_updated instant, and most of the protocol is a set of rules about which snapshot wins when two of them disagree. Get that ordering right and malformed bodies become a schema problem you can catch once. Get it wrong and you ship an integration that validates perfectly and bills the wrong number, a failure no schema check can see. The common integration pitfalls writeup collects the ones that survive a clean validation pass.