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

OCPI Sessions and CDRs: From Live Session to Billing Record

Pushing OCPI 2.2.1 Sessions, building CDRs with charging periods and CdrDimensionType, totals, and correcting mistakes with credit CDRs.

HashTech Engineering · October 26, 2026 · 6 min read

Sessions and CDRs are where roaming turns into money. A Session tells the eMSP what is happening right now, so its app can show energy delivered and running cost. A CDR (Charge Detail Record) is the final, immutable record the eMSP bills the driver from and the CPO invoices the eMSP from. Getting CDRs wrong is the most expensive OCPI bug there is, because it surfaces weeks later in a settlement dispute.

This is part 6 of the OCPI 2.2.1 series. Both modules are owned by the CPO.

Sessions

The CPO creates a session with PUT on the eMSP's Sessions Receiver as soon as it knows about it, and updates it with PATCH:

http
PUT /ocpi/emsp/2.2.1/sessions/DE/CPX/SESS-000123 HTTP/1.1
Host: emsp.example.com
Authorization: Token ZWJmM2IzOTktNzc5Zi00NDk3LTliOWQtYWM2YWQzY2M0NGQy
Content-Type: application/json
X-Request-ID: 3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9
X-Correlation-ID: 3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9
json
{
  "country_code": "DE",
  "party_id": "CPX",
  "id": "SESS-000123",
  "start_date_time": "2026-10-26T10:15:02Z",
  "kwh": 0,
  "cdr_token": {
    "country_code": "NL",
    "party_id": "EXA",
    "uid": "04A1B2C3D4E5F6",
    "type": "RFID",
    "contract_id": "NL-EXA-C12345678-X"
  },
  "auth_method": "AUTH_REQUEST",
  "authorization_reference": "AUTH-7f3c91",
  "location_id": "LOC-0042",
  "evse_uid": "3256",
  "connector_id": "1",
  "currency": "EUR",
  "status": "ACTIVE",
  "last_updated": "2026-10-26T10:15:04Z"
}
EnumValues
SessionStatusACTIVE, COMPLETED, INVALID, PENDING, RESERVATION
AuthMethodAUTH_REQUEST (real-time authorize), COMMAND (started by START_SESSION), WHITELIST (local decision)

PENDING covers the period after authorization but before energy flows, such as a remote start waiting for the cable. INVALID marks a session that will never produce a CDR, for example one that never actually started. RESERVATION is used for a session representing an active reservation.

During charging, PATCH the fields that changed:

json
{
  "kwh": 12.48,
  "total_cost": { "excl_vat": 3.74, "incl_vat": 4.49 },
  "last_updated": "2026-10-26T10:45:00Z"
}

How often to send these is a negotiation. Every OCPP MeterValues message is far too often; once a minute or on significant change is reasonable for most partners, and some eMSPs specify a limit. End the session with a PATCH that sets status: COMPLETED and end_date_time.

Charging periods and dimensions

Both sessions and CDRs carry charging_periods: an ordered list of ChargingPeriod objects, each starting at start_date_time and lasting until the next period starts (or the session ends). A new period begins whenever something that affects price changes: the tariff element that applies, or the state of charging (charging vs parked). Each period lists the dimensions consumed in it:

json
{
  "start_date_time": "2026-10-26T10:15:02Z",
  "dimensions": [
    { "type": "ENERGY", "volume": 18.2 },
    { "type": "TIME", "volume": 0.75 },
    { "type": "MAX_POWER", "volume": 48.6 }
  ],
  "tariff_id": "DC-BASIC"
}
CdrDimensionTypeUnit / meaning
ENERGYkWh delivered in the period
TIMEHours charging
PARKING_TIMEHours connected but not charging
RESERVATION_TIMEHours reserved
MAX_CURRENT / MIN_CURRENTA, max/min during the period
MAX_POWER / MIN_POWERkW, max/min during the period
CURRENTAverage current in A
POWERAverage power in kW
ENERGY_EXPORT / ENERGY_IMPORTkWh exported / imported (relevant for bidirectional charging)
STATE_OF_CHARGEVehicle SoC in percent

Time-based volumes are in hours, as decimals. 45 minutes is 0.75, not 45 and not 2700. Some dimension types are intended for live sessions only and are marked in the specification as not to be used in CDRs; check the spec's table before putting instantaneous values such as STATE_OF_CHARGE into a CDR.

CDRs

When the session is complete and the final meter reading is known, the CPO creates a CDR with POST to the eMSP's CDRs Receiver:

http
POST /ocpi/emsp/2.2.1/cdrs HTTP/1.1
Host: emsp.example.com
Authorization: Token ZWJmM2IzOTktNzc5Zi00NDk3LTliOWQtYWM2YWQzY2M0NGQy
Content-Type: application/json
X-Request-ID: 6f5e4d3c-2b1a-4098-8f7e-6d5c4b3a2918
X-Correlation-ID: 6f5e4d3c-2b1a-4098-8f7e-6d5c4b3a2918
json
{
  "country_code": "DE",
  "party_id": "CPX",
  "id": "CDR-000123",
  "start_date_time": "2026-10-26T10:15:02Z",
  "end_date_time": "2026-10-26T11:25:00Z",
  "session_id": "SESS-000123",
  "cdr_token": {
    "country_code": "NL",
    "party_id": "EXA",
    "uid": "04A1B2C3D4E5F6",
    "type": "RFID",
    "contract_id": "NL-EXA-C12345678-X"
  },
  "auth_method": "AUTH_REQUEST",
  "authorization_reference": "AUTH-7f3c91",
  "cdr_location": {
    "id": "LOC-0042",
    "address": "Hafenstrasse 12",
    "city": "Hamburg",
    "postal_code": "20457",
    "country": "DEU",
    "coordinates": { "latitude": "53.543210", "longitude": "9.987650" },
    "evse_uid": "3256",
    "evse_id": "DE*CPX*E0042*1",
    "connector_id": "1",
    "connector_standard": "IEC_62196_T2_COMBO",
    "connector_format": "CABLE",
    "connector_power_type": "DC"
  },
  "currency": "EUR",
  "charging_periods": [
    {
      "start_date_time": "2026-10-26T10:15:02Z",
      "dimensions": [
        { "type": "ENERGY", "volume": 24.0 },
        { "type": "TIME", "volume": 1.0 }
      ],
      "tariff_id": "DC-BASIC"
    },
    {
      "start_date_time": "2026-10-26T11:15:02Z",
      "dimensions": [
        { "type": "PARKING_TIME", "volume": 0.1661 }
      ],
      "tariff_id": "DC-BASIC"
    }
  ],
  "total_cost": { "excl_vat": 9.70, "incl_vat": 11.54 },
  "total_energy": 24.0,
  "total_energy_cost": { "excl_vat": 9.60, "incl_vat": 11.42 },
  "total_time": 1.1661,
  "total_parking_time": 0.1661,
  "total_parking_cost": { "excl_vat": 0.10, "incl_vat": 0.12 },
  "last_updated": "2026-10-26T11:25:30Z"
}

The eMSP answers with a success status and a Location HTTP header pointing to where the CDR can be retrieved in its system. Store that URL; it is how you later check what the eMSP actually received.

Some fields that deserve care:

  • cdr_location is a snapshot of the location at charging time, not a reference. If the site is renamed next month, old CDRs keep the old name. Build it from your data at session end.
  • tariffs can embed the Tariff objects that were used, so the eMSP can verify the price even if the tariff has changed since.
  • total_time includes parking time; total_parking_time is the non-charging part of it.
  • total_cost is mandatory. The separate totals (total_fixed_cost, total_energy_cost, total_time_cost, total_parking_cost, total_reservation_cost) are optional but most eMSPs want them for invoice breakdowns.
  • signed_data can carry signed meter values for calibration-law (Eichrecht) compliance where it applies.
  • Amounts are numbers in the session's currency. Decide on rounding (normally to the currency's minor unit) and make sure the parts add up to total_cost; eMSPs do check.

Correcting a CDR: credit CDRs

CDRs are immutable. There is no PUT or PATCH on the CDRs Receiver. If a CDR was wrong, the correction is two new CDRs:

  1. A credit CDR with a new id, credit: true, credit_reference_id set to the original CDR's ID, and negative amounts that cancel the original.
  2. A new, correct CDR with yet another id.

Partners reconcile by summing everything for the session. Never reuse an ID, and never try to "fix" a CDR by posting a different body with the same ID; a well-behaved eMSP will reject it, and a less well-behaved one will silently keep both.

Delivery, retries and pull

The CDRs Sender interface lets the eMSP pull with GET {cdrs_url}?date_from=..., and many eMSPs do so daily as a reconciliation step even when pushes are in place. On the CPO side:

  • Keep CDRs in an outbox and retry pushes with backoff until you get 1000. A CDR that was never delivered is revenue you will struggle to claim later.
  • Make retries idempotent: posting the same CDR ID twice should be harmless. If the eMSP rejects a duplicate, record it as delivered rather than retrying forever.
  • Alert on CDRs stuck in the outbox. A partner who changed its endpoint and forgot to tell you shows up here first.

Common mistakes

  • No CDR for zero-energy sessions, or CDRs for sessions that never started. Agree with partners on how to handle very short or zero-kWh sessions; many expect either no CDR or a zero-cost one, and Session.status = INVALID for the ones that will not be billed.
  • Using local time in start_date_time. CDR times are UTC.
  • Charging periods that do not cover the session, overlap, or are out of order.
  • Cost in the session that never matches the cost in the CDR. A live estimate can differ, but the CDR is final and must be computed from the same tariff the eMSP has.

Key takeaways

  • Sessions are live, mutable and pushed with PUT/PATCH; CDRs are final, immutable and created with POST.
  • cdr_token carries the eMSP's identifiers; top-level identifiers are the CPO's.
  • Time dimensions are in hours; energy in kWh; power in kW.
  • Fix a CDR with a credit CDR plus a new CDR, never by editing.
  • Treat CDR delivery as a guaranteed-delivery outbox.

Next: Part 7, Tariffs, where we compute the costs that go into these CDRs.

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.