OCPI Sessions Module: Live Charging Session Tracking, Explained

How CPOs stream live session data to eMSPs over OCPI: when a session starts, how much energy has flowed, and how status changes get pushed.

If Locations is the map of available stations and Tariffs is the price list, Sessions is the live action. It is how a CPO tells an eMSP, while the car is still plugged in, “your customer started charging at 10:30, they have taken 12.4 kWh so far, and the session is still ACTIVE.”

This is the data that drives the “currently charging” view in an eMSP’s app. It is also the one module where the object you store gets rewritten dozens of times over the life of a single charge, which is where the implementation difficulty lives.

What’s inside the Session object

A Session represents one charging transaction (one driver, one station, one start/stop). The structure:

{
  "country_code": "NL",
  "party_id": "ABC",
  "id": "SESSION_12345",
  "start_date_time": "2026-10-01T10:30:00Z",
  "end_date_time": null,            // null while ongoing
  "kwh": 12.4,                       // running total
  "cdr_token": {                     // the customer's token (all 5 fields required)
    "country_code": "NL",
    "party_id": "ABC",
    "uid": "12345678905880",
    "type": "RFID",
    "contract_id": "NL-ABC-C12345678-X"
  },
  "auth_method": "AUTH_REQUEST",     // how authorization happened
  "authorization_reference": "AUTH_REF_7f3c21",
  "location_id": "LOC1",
  "evse_uid": "EVSE1",
  "connector_id": "CONN1",
  "meter_id": null,
  "currency": "EUR",
  "charging_periods": [              // detailed metering data
    {
      "start_date_time": "2026-10-01T10:30:00Z",
      "dimensions": [
        {"type": "ENERGY", "volume": 5.2},
        {"type": "TIME", "volume": 0.083}
      ],
      "tariff_id": "TARIFF1"
    }
  ],
  "total_cost": {                    // current estimated cost (with VAT breakdown)
    "excl_vat": 4.97,
    "incl_vat": 5.97
  },
  "status": "ACTIVE",                // ACTIVE, COMPLETED, INVALID, PENDING, RESERVATION
  "last_updated": "2026-10-01T10:38:24Z"
}

Key things to notice:

  • end_date_time is null while the session is in progress. When it gets a value, the session has ended.
  • kwh is the running total of energy delivered, rewritten on every update.
  • cdr_token identifies the customer’s token, and all five of its fields are required. country_code and party_id joined uid, type and contract_id in 2.2.1, so a counterparty still speaking 2.2 sends the shorter three-field version (what else moved in 2.2.1). It is the same identity object the Tokens module carries.
  • charging_periods is the detailed timeline. It splits into several segments when the rate changed or a different tariff element applied.
  • status captures the session lifecycle.

Status lifecycle

Sessions progress through several statuses:

  • PENDING: the session is set up but charging hasn’t actually started, because authorization or the vehicle handshake is still in progress
  • RESERVATION: the session was started by a reservation, and charging has not yet begun
  • ACTIVE: energy is flowing right now
  • COMPLETED: the session ended successfully, and this is its final state
  • INVALID: the Session is declared invalid (failed authorization, hardware fault) and will not be billed

The happy path is PENDING → ACTIVE → COMPLETED. A reserved session begins at RESERVATION before it becomes ACTIVE. INVALID leaves the success path entirely, and no CDR follows it.

stateDiagram-v2
    [*] --> PENDING: Session created
    [*] --> RESERVATION: Started by a reservation
    RESERVATION --> ACTIVE: Charging begins
    PENDING --> ACTIVE: Charging begins
    PENDING --> INVALID: Auth or handshake fails
    ACTIVE --> COMPLETED: Session ends cleanly
    ACTIVE --> INVALID: Hardware fault mid-session
    COMPLETED --> [*]: CDR follows
    INVALID --> [*]: No CDR

The publishing pattern

The CPO owns Session data, so the CPO is the Sender and the eMSP is the Receiver. Sessions are pushed rather than polled, for a reason that is obvious once you picture the screen: a driver watching a progress bar cannot wait for the next pull cycle. The eMSP’s GET against the Sender Interface exists for backfill and reconciliation, not for the live view. The trade-off between the two directions is worked through in pull vs push patterns (coming soon).

Every response to these pushes comes wrapped in an OCPI envelope, and the envelope’s status_code is the real result. HTTP 200 with status_code 2001 means your push was rejected, so a receiver that only checks the HTTP status will silently lose updates. OCPI error responses (coming soon) covers the code ranges.

Initial session push

When a session starts, the CPO PUTs the full Session object:

PUT /ocpi/emsp/2.2.1/sessions/NL/ABC/SESSION_12345
Authorization: Token cpo-credentials-token

{
  "country_code": "NL",
  "party_id": "ABC",
  "id": "SESSION_12345",
  "start_date_time": "2026-10-01T10:30:00Z",
  "end_date_time": null,
  "kwh": 0,
  "cdr_token": {                    // all 5 fields required
    "country_code": "NL",
    "party_id": "ABC",
    "uid": "12345678905880",
    "type": "RFID",
    "contract_id": "NL-ABC-C12345678-X"
  },
  "auth_method": "AUTH_REQUEST",
  "location_id": "LOC1",
  "evse_uid": "EVSE1",
  "connector_id": "CONN1",
  "currency": "EUR",
  "status": "PENDING",
  "last_updated": "2026-10-01T10:30:00Z"
}

Real-time updates via PATCH

As the session progresses, the CPO sends PATCH updates with only changed fields:

PATCH /ocpi/emsp/2.2.1/sessions/NL/ABC/SESSION_12345
Authorization: Token cpo-credentials-token

{
  "kwh": 12.4,
  "status": "ACTIVE",
  "total_cost": {"excl_vat": 4.97, "incl_vat": 5.97},
  "last_updated": "2026-10-01T10:38:24Z"
}

OCPI does not mandate an update rate, so the cadence is something the two parties agree bilaterally. The trade-off sets it: push too rarely and the driver’s app shows a kWh figure that is visibly stale; push on every meter tick and you flood the receiver with writes it has to de-duplicate. Whatever cadence you land on, write it into the connection agreement and make sure last_updated moves on every send, because that field is what the receiver uses to tell a real change from a repeat.

Final update at session end

When the session ends:

PATCH /ocpi/emsp/2.2.1/sessions/NL/ABC/SESSION_12345

{
  "end_date_time": "2026-10-01T10:55:23Z",
  "kwh": 28.7,
  "status": "COMPLETED",
  "total_cost": {"excl_vat": 11.52, "incl_vat": 13.82},
  "last_updated": "2026-10-01T10:55:25Z"
}

After the COMPLETED Session update, the CPO follows up with a CDR (Charge Detail Record) via the CDRs module. That is the official, immutable billing record.

A complete worked example: CPO streams, eMSP reads

The snippets above are trimmed for clarity. Here is the full life of one session on the wire: the CPO pushing the start, streaming updates, closing it out, and the eMSP reading sessions back. The details that trip people up are called out in the comments. (Comments use // and # for teaching; real JSON has no comments.) The same wire-level treatment for the other modules lives in the OCPI request examples collection.

1. Session starts: the CPO PUTs the full Session (to the eMSP)

# ── CPO ──▶ eMSP ────────────────────────────────────────────────────────────
# The CPO owns Session data, so it PUSHES to the eMSP's Receiver Interface.
# PUT = "here is the complete, authoritative Session; store it under this id."
# Use PUT once at the start (or to fully overwrite); use PATCH for every
# subsequent change so you only send what moved.

PUT /ocpi/emsp/2.2.1/sessions/NL/TNM/SESSION_12345 HTTP/1.1
Host: emsp.example.com
# Auth scheme is the literal word "Token", NOT "Bearer", then the credentials
# token the eMSP issued to THIS CPO during the Credentials handshake.
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json
# Recommended so both sides can trace one call across their logs; echo them back.
X-Request-ID: 12345
X-Correlation-ID: 67890

# The URL path carries {country_code}/{party_id}/{session_id}. They MUST equal
# the country_code / party_id / id in the body. A mismatch gets rejected.
{
  "country_code": "NL",             // ISO 3166-1 alpha-2 of the CPO (2 chars)
  "party_id": "TNM",                // the CPO's party id (3 chars)
  "id": "SESSION_12345",            // Session id, unique within this CPO
  "start_date_time": "2026-10-01T10:30:00Z",  // RFC 3339, UTC ("Z")
  "end_date_time": null,            // null while the session is still running
  "kwh": 0,                         // RUNNING TOTAL of energy delivered; starts at 0
  "cdr_token": {                    // identifies the customer's token
    "country_code": "NL",
    "party_id": "TNM",
    "uid": "12345678905880",        // the token uid that was presented
    "type": "RFID",
    "contract_id": "NL-TNM-C12345678-X"
  },
  // How the driver was authorized: AUTH_REQUEST (CPO asked eMSP live) |
  // COMMAND (an OCPI start_session command kicked it off) | WHITELIST (token
  // was pre-shared, no live auth needed).
  "auth_method": "AUTH_REQUEST",    // required
  // OPTIONAL. The reference the eMSP returned in its authorization response
  // (or the one you sent with a start_session COMMAND). Present here already
  // because AUTH_REQUEST authorization completed before the session was created;
  // absent only when there was no such reference (e.g. plain WHITELIST).
  "authorization_reference": "AUTH_REF_7f3c21",
  "location_id": "LOC1",            // required. The three below point at exactly one plug
  "evse_uid": "3256",               // required. The EVSE's uid within that Location
  "connector_id": "1",              // required. The Connector's id within that EVSE
  "currency": "EUR",                // required. ISO 4217; fixed for the whole session
  "status": "PENDING",              // required. Set up, but energy is not flowing yet
  // RFC 3339, UTC ("Z"). Receivers use last_updated to detect real changes and
  // to resolve out-of-order updates (newest wins). Bump it on EVERY change or
  // downstream state silently goes stale.
  "last_updated": "2026-10-01T10:30:00Z"   // required
  // The remaining OPTIONAL Session fields have no value yet in PENDING. They are
  // legitimately absent until they apply, not "omitted for brevity":
  //   end_date_time    → set when status → COMPLETED (the session has ended).
  //   meter_id         → sent once the EVSE reports its meter id (usually at the
  //                      first ACTIVE update, when metering begins).
  //   charging_periods → first appears when metering starts (status → ACTIVE);
  //                      grows as the session crosses tariff/time boundaries.
  //   total_cost       → a running estimate; appears once there is metered energy
  //                      to price (status → ACTIVE), then updates each PATCH.
}
# ── eMSP ──▶ CPO (the response) ─────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 12345
X-Correlation-ID: 67890

{
  "data": {},                       // nothing to return for a PUT; the envelope still needs the field
  // THE key OCPI gotcha: HTTP 200 only means the HTTP call arrived. You must
  // ALSO check status_code INSIDE the envelope. 1000 = success. You can get
  // HTTP 200 with status_code 2001 (invalid parameters), so always read the
  // envelope, never trust the HTTP status alone. 2xxx = client error, 3xxx = server.
  "status_code": 1000,
  "status_message": "Success",
  "timestamp": "2026-10-01T10:30:01Z"
}

2. Mid-session: the CPO PATCHes only what changed

# ── CPO ──▶ eMSP ────────────────────────────────────────────────────────────
# Now the car is charging. PATCH carries ONLY the fields that moved, and the eMSP
# MERGES them into the Session it already holds (do NOT treat PATCH like PUT;
# that would wipe the unsent fields).

PATCH /ocpi/emsp/2.2.1/sessions/NL/TNM/SESSION_12345 HTTP/1.1
Host: emsp.example.com
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json

{
  "status": "ACTIVE",              // PENDING → ACTIVE: energy is now flowing
  // kwh is the RUNNING TOTAL so far, NOT the delta since the last PATCH. If a
  // PATCH is missed, the next one still carries the correct absolute value.
  "kwh": 12.4,
  // OPTIONAL. First carried now that metering has started: the meter id the EVSE
  // reported. Once sent it stays constant for the session.
  "meter_id": "MTR-3256-A",
  // OPTIONAL. Unchanged since the start push; re-sent here only to show the full
  // ACTIVE shape (a real PATCH may omit fields that did not change).
  "authorization_reference": "AUTH_REF_7f3c21",
  "charging_periods": [            // OPTIONAL. The metered timeline of the session so far
    {
      "start_date_time": "2026-10-01T10:30:00Z",  // required within each period
      "dimensions": [                              // required; each has type + volume
        { "type": "ENERGY", "volume": 12.4 },  // kWh in this period
        { "type": "TIME", "volume": 0.133 }     // hours (~8 min)
      ],
      "tariff_id": "TARIFF1"       // OPTIONAL. The tariff in effect during this period
    }
  ],
  "total_cost": {                  // OPTIONAL. A running ESTIMATE, not the billable amount
    "excl_vat": 4.97,              // required within Price (amount without VAT)
    "incl_vat": 5.97               // OPTIONAL within Price (amount including VAT)
  },
  // Bump on every PATCH. The eMSP compares this to what it holds and applies
  // only if it is NEWER. That is how out-of-order PATCHes are resolved.
  "last_updated": "2026-10-01T10:38:24Z"
}
# ── eMSP ──▶ CPO (the response) ─────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {},
  "status_code": 1000,             // still check this, not just the HTTP 200
  "status_message": "Success",
  "timestamp": "2026-10-01T10:38:25Z"
}

3. Session ends: the final PATCH (→ COMPLETED)

# ── CPO ──▶ eMSP ────────────────────────────────────────────────────────────
# The driver unplugs. The closing PATCH sets end_date_time, the final running
# kwh total, the final status, and the last estimated cost.

PATCH /ocpi/emsp/2.2.1/sessions/NL/TNM/SESSION_12345 HTTP/1.1
Host: emsp.example.com
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json

{
  "end_date_time": "2026-10-01T10:55:23Z",  // OPTIONAL: now set → session is over
  "kwh": 28.7,                     // the FINAL running total (still absolute)
  "status": "COMPLETED",           // ACTIVE → COMPLETED: the session's end state
  "meter_id": "MTR-3256-A",        // OPTIONAL. Unchanged from ACTIVE; the EVSE's meter id
  "authorization_reference": "AUTH_REF_7f3c21",  // OPTIONAL. Same reference as the start
  // OPTIONAL. The COMPLETE final timeline. Here the session crossed a tariff
  // boundary, so there are two contiguous periods (peak → off-peak).
  "charging_periods": [
    {
      "start_date_time": "2026-10-01T10:30:00Z",  // required within each period
      "dimensions": [                              // required; each has type + volume
        { "type": "ENERGY", "volume": 16.9 },  // kWh delivered in the peak period
        { "type": "TIME", "volume": 0.30 }      // hours in this period (~18 min)
      ],
      "tariff_id": "TARIFF_PEAK"   // OPTIONAL. Tariff in effect for this period
    },
    {
      "start_date_time": "2026-10-01T10:48:00Z",  // required within each period
      "dimensions": [                              // required; each has type + volume
        { "type": "ENERGY", "volume": 11.8 },  // kWh delivered in the off-peak period
        { "type": "TIME", "volume": 0.12 }      // hours in this period (~7 min)
      ],
      "tariff_id": "TARIFF_OFFPEAK"  // OPTIONAL. Switched tariffs mid-session
    }
  ],
  "total_cost": {                  // OPTIONAL. Still an estimate; bill off the CDR
    "excl_vat": 11.52,             // required within Price (amount without VAT)
    "incl_vat": 13.82              // OPTIONAL within Price (amount including VAT)
  },
  "last_updated": "2026-10-01T10:55:25Z"
}
# After COMPLETED, the CPO follows up with a CDR (via the CDRs module): the
# immutable, billable record. The Session total_cost stays an estimate; bill off
# the CDR, not this.
# ── eMSP ──▶ CPO (the response) ─────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {},
  "status_code": 1000,
  "status_message": "Success",
  "timestamp": "2026-10-01T10:55:26Z"
}

4. The eMSP reads sessions back (GET from the CPO)

# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# The eMSP reads from the CPO's Sender Interface, e.g. to backfill after an
# outage or to reconcile. The list endpoint is paginated: follow the pages or
# you only ever see the first slice.

GET /ocpi/cpo/2.2.1/sessions?date_from=2026-10-01T00:00:00Z&offset=0&limit=50 HTTP/1.1
Host: cpo.example.com
# Opposite direction, opposite token: the one the CPO issued to the eMSP.
Authorization: Token <token-the-CPO-issued-to-this-eMSP>
# Query params:
#   date_from / date_to → only sessions changed in this window (delta sync)
#   offset / limit      → paging; the CPO may cap limit, so read the real one back
#                          from the response headers below.
# ── CPO ──▶ eMSP (the response) ─────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
# Pagination lives in HEADERS, not the body:
#   Link          → URL of the NEXT page; keep following until there is no "next"
#   X-Total-Count → total sessions matching the filter, across ALL pages
#   X-Limit       → the page size the CPO actually applied (can be < your limit)
Link: <https://cpo.example.com/ocpi/cpo/2.2.1/sessions?date_from=2026-10-01T00:00:00Z&offset=50&limit=50>; rel="next"
X-Total-Count: 213
X-Limit: 50

{
  "data": [                        // an ARRAY of Session objects (this page only)
    {
      // The CPO returns the COMPLETE Session object, every field it holds. This
      // one is COMPLETED, so all the optional fields have their final values.
      "country_code": "NL",        // required
      "party_id": "TNM",           // required
      "id": "SESSION_12345",       // required
      "start_date_time": "2026-10-01T10:30:00Z",  // required
      "end_date_time": "2026-10-01T10:55:23Z",    // OPTIONAL; set because COMPLETED
      "kwh": 28.7,                 // required. The running total at the last update the CPO holds
      "cdr_token": {               // required. Identifies the customer's token (all 5 fields required)
        "country_code": "NL",      // country_code of the eMSP that issued the token
        "party_id": "TNM",         // party_id that issued/owns the token
        "uid": "12345678905880",   // the token uid that was presented
        "type": "RFID",            // AD_HOC_USER | APP_USER | OTHER | RFID
        "contract_id": "NL-TNM-C12345678-X"  // the eMA-I / contract id of the driver
      },
      "auth_method": "AUTH_REQUEST",  // required
      "authorization_reference": "AUTH_REF_7f3c21",  // OPTIONAL; the authorization reference
      "location_id": "LOC1",       // required
      "evse_uid": "3256",          // required
      "connector_id": "1",         // required
      "meter_id": "MTR-3256-A",    // OPTIONAL; the EVSE's meter id
      "currency": "EUR",           // required. ISO 4217
      "charging_periods": [        // OPTIONAL. The complete final metered timeline
        {
          "start_date_time": "2026-10-01T10:30:00Z",  // required within each period
          "dimensions": [                              // required; each has type + volume
            { "type": "ENERGY", "volume": 16.9 },  // kWh in the peak period
            { "type": "TIME", "volume": 0.30 }      // hours in this period
          ],
          "tariff_id": "TARIFF_PEAK"   // OPTIONAL. Tariff in effect for this period
        },
        {
          "start_date_time": "2026-10-01T10:48:00Z",  // required within each period
          "dimensions": [                              // required; each has type + volume
            { "type": "ENERGY", "volume": 11.8 },  // kWh in the off-peak period
            { "type": "TIME", "volume": 0.12 }      // hours in this period
          ],
          "tariff_id": "TARIFF_OFFPEAK"  // OPTIONAL. Switched tariffs mid-session
        }
      ],
      "total_cost": {              // OPTIONAL. The last estimate; the CDR is the billable value
        "excl_vat": 11.52,         // required within Price (amount without VAT)
        "incl_vat": 13.82          // OPTIONAL within Price (amount including VAT)
      },
      "status": "COMPLETED",       // required
      "last_updated": "2026-10-01T10:55:25Z"   // required
    }
    // … up to `limit` Sessions, then follow the Link header for the next page.
  ],
  "status_code": 1000,             // again: check this, not just the HTTP 200
  "status_message": "Success",
  "timestamp": "2026-10-01T11:00:00Z"
}

Sessions vs CDRs: the crucial distinction

Both objects describe the same charging session, but they answer different questions:

SessionCDR
WhenDuring and just after a charging sessionAfter the session has fully completed
MutableYes, rewritten many timesNo, immutable once created
PurposeLive UX, real-time monitoringBilling, accounting, audit trail
FrequencyMultiple per session (updates)One per session (final)
LifecyclePENDING → ACTIVE → COMPLETED/INVALIDCreated once after session ends

The Session is the running narrative; the CDR is the closing statement.

Charging periods: the granular timeline

The charging_periods array within a Session captures detail about how the session unfolded:

"charging_periods": [
  {
    "start_date_time": "2026-10-01T10:30:00Z",
    "dimensions": [
      {"type": "ENERGY", "volume": 5.2},
      {"type": "TIME", "volume": 0.083}
    ],
    "tariff_id": "TARIFF_PEAK"
  },
  {
    "start_date_time": "2026-10-01T10:38:00Z",
    "dimensions": [
      {"type": "ENERGY", "volume": 7.2},
      {"type": "TIME", "volume": 0.117}
    ],
    "tariff_id": "TARIFF_OFFPEAK"   // switched tariffs mid-session
  }
]

Each period has:

  • A start time
  • Dimensions, which name what was measured: energy in kWh, time elapsed, parking time, and the rest of the tariff dimension set
  • The tariff in effect during that period

Multiple periods occur when:

  • The session crosses a time-of-use boundary (peak to off-peak)
  • The session crosses an idle/charging boundary, so PARKING_TIME starts accruing instead of ENERGY
  • The CPO puts a different tariff_id on a later period, which is how a tariff swap mid-session is expressed on the wire

Auth method and authorization reference

The auth_method and authorization_reference fields capture how the driver was authorized to charge:

  • AUTH_REQUEST: the CPO asked the eMSP live over OCPI, and the eMSP authorized
  • COMMAND: an OCPI Command (start_session) initiated this session, so the eMSP had already authorized by definition
  • WHITELIST: the token was on a pre-shared list, so no live authorization call was needed

authorization_reference is a CPO-assigned identifier that lets you tie the session back to the authorization event.

How the eMSP uses Session data

The eMSP’s app uses Session data to:

  1. Show a “currently charging” state to the driver: “Your session at Site X started at 10:30 and has delivered 12.4 kWh so far.”
  2. Derive live progress. The Session object carries no instantaneous power field, so an app computes one by differencing successive kwh values against their last_updated timestamps. That gives average power over the interval, which is what a progress screen actually needs.
  3. Show a predicted cost, either from the CPO’s own total_cost estimate or by pricing the running kwh against the applicable Tariff.
  4. Send push notifications on state changes: session started, session ended, session went INVALID.
  5. Trigger end-of-session flows when status flips to COMPLETED: park a receipt placeholder, then fill it from the CDR.

When the Session status transitions to COMPLETED, the app should show “session ended, receipt on its way” rather than a number. The final, defensible total comes from the CDR, and until that arrives there is nothing worth putting in front of a driver.

Implementation gotchas

kwh is a running total, not a delta

It is tempting to read kwh on a PATCH as the energy delivered since the last PATCH. It is not. kwh is always the absolute running total for the whole session so far. That design choice matters because updates over HTTP are lossy and unordered:

  • A PATCH gets missed (network blip). If kwh were a delta, the eMSP would now be permanently short by that increment. Because it is a running total, the very next PATCH silently brings the eMSP back to the correct absolute value, with no reconciliation needed.
  • Two PATCHes arrive out of order. The tiebreaker is last_updated, and newer wins. Applying an older PATCH after a newer one would clobber fresh data (the classic “the session went backwards” bug). Always compare last_updated before applying; drop the update if what you already hold is newer.

Concretely, here is a correct sequence, even with a dropped update in the middle:

// PATCH #1  last_updated 10:38:24Z  → kwh 12.4   (apply: newer than what we hold)
// PATCH #2  last_updated 10:45:10Z  → kwh 20.1   (LOST in transit, never arrives)
// PATCH #3  last_updated 10:52:33Z  → kwh 26.8   (apply: still the absolute total,
//                                                 so we jump straight to 26.8 with no
//                                                 need to have seen #2)

// And if #3 had arrived BEFORE a delayed #1:
//   hold 26.8 (last_updated 10:52:33Z), then #1 shows up (10:38:24Z) →
//   10:38:24Z < 10:52:33Z, so DROP #1. Do not overwrite 26.8 with 12.4.

Because each PATCH carries the latest accurate absolute value, a single received update is always enough to make the eMSP current, provided it is applied only when it is the newest one seen. See the OCPI 2.2.1 spec’s Sessions module for the object definition and the last_updated semantics.

Reconciliation against the CDR

Here is the shape of the mismatch: the Session closes at total_cost.incl_vat = 13.82 and the CDR arrives at 14.15. A small difference is expected, because the Session is an estimate while the CDR is final and can fold in rounding rules or one last meter reading. A large gap is a different animal. It means the two sides priced different charging_periods, or one applied a tariff element the other never saw. Set a threshold, alert above it, and store the Session and the CDR side by side so the diff is inspectable when a driver disputes a charge months later. Reconciliation sits alongside the other receive-side traps gathered in OCPI integration pitfalls.

Sessions for unfinished charges

If a CPO marks a Session as COMPLETED but never sends a CDR, the eMSP doesn’t know what to bill. Implementations need a timeout: if no CDR arrives within an agreed window after session completion, raise an alert. Any fallback to the Session figures should be a flagged exception with a human in the loop, not a quiet default.

Charging periods overlap

The charging_periods array should be non-overlapping and time-contiguous: each period starts where the previous one ended. Gaps and overlaps creep in when a CPO assembles periods from separate meter streams and stitches them after the fact. Validate contiguity at receive time, before the periods reach anything that prices them, because a one-second overlap double-counts the energy inside it.

The part that bites later

The obvious way to build the receiving side is as a mirror: whatever arrives, store it. That holds up until the first out-of-order PATCH, because a mirror has no opinion about which version of the truth is newer.

The fix is to stop treating a Session as a row you overwrite and start treating it as a stream you fold. Every PATCH carries an absolute kwh and a last_updated, so the only correct receive operation is “apply if strictly newer than what I hold, otherwise drop.” Once that rule lives in the receiver, missed pushes and duplicate retries both stop mattering, and the CPO can push at whatever cadence it likes without a driver ever watching a number count backwards.

The second habit is harder to retrofit: nothing in the Sessions module is billable. total_cost is the CPO’s live estimate and is allowed to be wrong. The moment someone asks to “just use the session total so the receipt is instant,” you are one rounding rule away from charging an amount you cannot defend in a dispute. Show the estimate, label it an estimate, and settle from the CDR.

The status that is easiest to leave unhandled is INVALID. A flow that waits for a CDR after COMPLETED is correct. The same flow parked on an INVALID session waits forever, because no CDR is ever coming. Give INVALID its own exit, or the “receipt on its way” banner you showed the driver becomes permanent.

Quick check

Q1. A Session's status is PENDING. What does that mean?

Frequently asked questions

What is the OCPI Sessions module for?

It carries live data about an in-progress charging session from CPO to eMSP: start time, energy delivered so far, the connector in use, and the current status. It lets an eMSP app show a driver their charging progress while the car is still plugged in.

How is a Session different from a CDR?

A Session is live, in-progress data (or just-completed). A CDR (Charge Detail Record) is the final, immutable billing record created after a Session ends. The two have related but different roles.

Found this useful? Share it.