OCPP 2.0.1 made one of its biggest changes to how charging sessions are tracked. The 1.6 trio of StartTransaction, MeterValues, and StopTransaction became a single unified message type: TransactionEvent. The change was driven by years of operational experience with 1.6’s separate-messages model and the bugs it surfaced.
This article covers what TransactionEvent is, how it works, and what changed for CSMS implementers moving from 1.6.
The change in one paragraph
In OCPP 1.6, a session is bracketed by StartTransaction and StopTransaction, with MeterValues messages between them. Three message types, all carrying part of the session story, all needing to be correlated by transactionId at the CSMS. In OCPP 2.0.1, a single TransactionEvent message replaces all three. The event-type field (Started, Updated, Ended) distinguishes which moment of the session this event represents. Same data, cleaner envelope.
flowchart LR
A[Started<br/>seqNo 0] --> B[Updated<br/>seqNo 1..n]
B --> B
B --> C[Ended<br/>final seqNo]
style A fill:#e8f5e9,stroke:#2e7d32
style C fill:#ffebee,stroke:#c62828
For background on how these messages travel and how a station connects to a CSMS, see What is OCPP. For the 1.6 model this replaces, see OCPP StartTransaction and StopTransaction (coming soon).
The structure
A typical TransactionEvent (Started):
{
"eventType": "Started",
"timestamp": "2026-06-26T19:00:00Z",
"triggerReason": "Authorized",
"seqNo": 0,
"transactionInfo": {
"transactionId": "abc123-def456",
"chargingState": "Charging",
"remoteStartId": null
},
"evse": {
"id": 1,
"connectorId": 1
},
"idToken": {
"idToken": "USER123XYZ",
"type": "ISO14443"
},
"meterValue": [ ... ]
}
A TransactionEvent (Updated) during the session:
{
"eventType": "Updated",
"timestamp": "2026-06-26T19:15:00Z",
"triggerReason": "MeterValueClock",
"seqNo": 12,
"transactionInfo": {
"transactionId": "abc123-def456",
"chargingState": "Charging"
},
"evse": { "id": 1, "connectorId": 1 },
"meterValue": [ ... ]
}
A TransactionEvent (Ended):
{
"eventType": "Ended",
"timestamp": "2026-06-26T20:00:00Z",
"triggerReason": "EVCommunicationLost",
"seqNo": 45,
"transactionInfo": {
"transactionId": "abc123-def456",
"stoppedReason": "EVDisconnected",
"chargingState": "Idle"
},
"evse": { "id": 1, "connectorId": 1 },
"meterValue": [ ... final readings ... ]
}
The fields:
- eventType: Started, Updated, or Ended.
- timestamp: when this event happened.
- triggerReason: why this event was generated (Authorized, MeterValueClock, MeterValuePeriodic, EVCommunicationLost, StopAuthorized, etc.).
- seqNo: sequence number within this transaction (0 for Started, increments for each Updated, last value for Ended).
- transactionInfo: transactionId plus session state.
- evse: which EVSE and connector.
- idToken: the user identifier (typically only in Started events).
- meterValue: meter readings at this point in time, using the same structure as the standalone MeterValues message (coming soon).
Why seqNo matters
The seqNo field is new compared to 1.6 and operationally valuable.
Sequence numbers within a transaction let the CSMS detect:
- Out-of-order delivery. Event 12 arrives before event 11 — your CSMS knows to reorder.
- Missing events. Event 10 received, event 11 missing, event 12 received — your CSMS knows to query for the missing data.
- Duplicate events. Two events with the same seqNo arrive — your CSMS knows to deduplicate.
In OCPP 1.6, you correlated MeterValues by timestamp, which was imprecise. The seqNo in 2.0.1 gives you a clean ordering primitive.
The triggerReason field
triggerReason tells you WHY this event was generated, which is operationally useful.
Common values:
- Authorized — user just got authorized, starting transaction.
- MeterValueClock — clock-aligned periodic sample (e.g. on the minute).
- MeterValuePeriodic — interval-aligned periodic sample (e.g. every 60 seconds).
- CablePluggedIn — vehicle was plugged in.
- EVCommunicationLost — vehicle stopped talking.
- EVConnectTimeout — vehicle never engaged after plug-in.
- StopAuthorized — CSMS approved a stop request.
- TriggerMessage — generated in response to a TriggerMessage from CSMS.
- UnlockCommand — CSMS issued UnlockConnector.
- TransactionEvent — session-related event triggered the data send.
This is genuinely useful for operational analysis. “Why did this session end?” becomes a one-field lookup rather than a heuristic from MeterValues plus StopTransaction reason.
How this maps to 1.6
A rough translation table:
| OCPP 1.6 | OCPP 2.0.1 |
|---|---|
| StartTransactionRequest | TransactionEventRequest (eventType=Started) |
| MeterValuesRequest (during transaction) | TransactionEventRequest (eventType=Updated) |
| StopTransactionRequest | TransactionEventRequest (eventType=Ended) |
| MeterValuesRequest (no active transaction) | MeterValuesRequest (still exists in 2.0.1 for non-transactional meter data) |
| StartTransactionResponse.transactionId | TransactionEventResponse no longer assigns the ID — the charger generates it (typically UUID) |
| StartTransactionResponse.idTagInfo | TransactionEventResponse.idTokenInfo (similar structure) |
A few important differences:
Charger generates transactionId. In 1.6, the CSMS issued the transactionId in StartTransactionResponse. In 2.0.1, the charger generates its own (often a UUID). Cleaner — no race condition where the charger sends MeterValues before getting the transactionId back.
Authorize check happens differently. The CSMS validates the user via the AuthorizeRequest message (still separate) OR via the idToken field in TransactionEvent (Started) for some flows. The CSMS can also reject a transaction via TransactionEventResponse if needed.
No more reconciliation. The state machine is cleaner because the charger reports its current state in each event.
Migration considerations
If you’re a CSMS migrating from 1.6 to 2.0.1, the TransactionEvent change requires real engineering:
Unified message handler. Your old StartTransaction handler, MeterValues handler, and StopTransaction handler need to merge into a TransactionEvent handler that dispatches on eventType.
TransactionId source change. Your old code assumed it generated transactionId; new code consumes the charger-generated ID.
Sequence number handling. Add logic to track seqNo per transaction and handle out-of-order or missing events.
State machine restructuring. The transaction state machine in your CSMS is simpler — but the migration involves rewriting state transitions.
Backward compatibility. Most CSMSes run 1.6 and 2.0.1 in parallel for years. Your transaction storage layer needs to handle both message families and represent both consistently in your database.
Reconciliation jobs. Update them to handle TransactionEvent (Ended) seqNo as the closure signal rather than StopTransaction.
A reasonable migration plan: implement TransactionEvent handling in parallel with continued 1.6 support, route per-charger based on the OCPP version negotiated, gradually onboard new chargers to 2.0.1 while existing 1.6 chargers continue to work. For a broader view of what else differs between the versions you are bridging, see the OCPP version comparison (coming soon).
Common implementation pitfalls
A few things to watch for.
Treating Started, Updated, and Ended as completely separate messages. They share transactionId; treat them as parts of the same transaction lifecycle. Your handler should fetch existing transaction state for Updated and Ended events.
Ignoring the chargingState field. transactionInfo.chargingState (Charging, EVConnected, SuspendedEV, SuspendedEVSE, Idle) is genuinely useful. Use it to track session state without re-deriving from meter readings.
Not handling seqNo gaps. A missing seqNo means a TransactionEvent was lost in transit. Your code should know how to recover (a TriggerMessage (coming soon) to ask for it, or accept the gap with a flag for later review).
Not handling out-of-order events. Two Updated events may arrive in reverse order. Use seqNo to order them, not arrival time.
Treating idToken as required in every event. idToken is typically only in Started; subsequent events reference the transactionId. Your code should handle either case.
What chargers should do
For OCPP 2.0.1 charger firmware:
- Generate unique transactionId. UUID is the conventional choice. Persist locally so a charger reboot doesn’t generate duplicate IDs.
- Increment seqNo strictly. Per transaction, starting at 0, never skip.
- Buffer events on disconnect. If the WebSocket dies mid-session, buffer TransactionEvents locally and replay on reconnect.
- Report chargingState accurately. Don’t lazily set it to “Charging” if the actual state is “SuspendedEV.”
- Set triggerReason accurately. It’s diagnostic gold for operators; don’t always set it to a generic value.
What this means in practice
For a developer working on either side, the OCPP 2.0.1 TransactionEvent change is one of the bigger structural shifts from 1.6. It rewards careful design (cleaner state machines, easier reconciliation) but requires real migration work. The data semantics are the same — meter readings, timestamps, transactionId tracking, billing math — but the message envelope is different enough that you can’t just rename functions.
For operations teams, TransactionEvent provides better diagnostic information (triggerReason, seqNo, chargingState) that improves debuggability. The investment in implementing 2.0.1 pays back partly in this improved observability.
The honest summary
OCPP 2.0.1’s TransactionEvent is one of the cleanest improvements over 1.6. The unified message type, sequence numbers, and explicit trigger reasons make production systems easier to build and debug. The migration from 1.6 is meaningful engineering work but the result is durable. If you’re building new OCPP code today, TransactionEvent is the right model to design around.