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

OCPP 2.0.1 TransactionEvent: Started, Updated, Ended Explained

A deep dive into OCPP 2.0.1 TransactionEvent: eventType, triggerReason, seqNo, TxStartPoint/TxStopPoint, offline queuing and CSMS-side handling.

HashTech Engineering · October 26, 2026 · 6 min read

In OCPP 1.6 a charging session is StartTransaction, a stream of MeterValues and a StopTransaction, and the CSMS hands out the transactionId. In 2.0.1 all of that becomes one message, TransactionEvent, sent by the station with an eventType of Started, Updated or Ended. It is the most important message in the protocol for billing, and the one with the most edge cases. This article is functional block E (Transactions) as you would implement it.

The anatomy of TransactionEventRequest

FieldNotes
eventTypeStarted, Updated or Ended.
timestampWhen the event happened at the station (not when it was sent).
triggerReasonWhy this event was sent now. Required on every event.
seqNoPer-transaction sequence number, starting at 0 and incremented for every TransactionEvent of that transaction.
transactionInfotransactionId (required, string, generated by the station), plus optional chargingState, timeSpentCharging, stoppedReason, remoteStartId.
offlineTrue if the event occurred while the station was offline.
evseEVSE (and connector) the transaction is on. Sent in the first event after the EV is connected; may be omitted in later events.
idTokenThe token that authorized (or stopped) the transaction, sent in the event where that happened.
meterValueArray of MeterValueType, each with a timestamp and sampledValue list.
numberOfPhasesUsed, cableMaxCurrent, reservationIdOptional context.

The transactionId is a string of up to 36 characters, typically a UUID, created by the station. The CSMS must key transactions by station identity plus transactionId, not by transactionId alone, and must not expect integers. This is the change that breaks the most 1.6-era database schemas.

When does a transaction start and stop?

In 1.6 a transaction started when the station said so, which in practice meant "after authorization and plug-in" with vendor variations. 2.0.1 makes this configurable through two device model variables on TxCtrlr:

  • TxStartPoint: a list of conditions. The transaction starts as soon as any of them becomes true.
  • TxStopPoint: a list of conditions. The transaction ends as soon as any of them is no longer true.
ValueMeaning
ParkingBayOccupancyA vehicle is detected in the bay (needs a sensor).
EVConnectedThe cable is plugged in at both ends.
AuthorizedThe driver or EV has been authorized.
DataSignedA signed meter value has been obtained (for eichrecht-style metering).
PowerPathClosedConnected and authorized, so energy can flow (relay closed).
EnergyTransferEnergy is actually being transferred.

A common configuration is TxStartPoint = EVConnected,Authorized and TxStopPoint = EVConnected,Authorized: the transaction exists from the first of plug-in or card tap, and ends when the cable is removed or authorization ends. With TxStartPoint = PowerPathClosed, a transaction only exists once both conditions hold, which means failed authorizations never create transactions at all. Neither is wrong, but billing, idle-fee and reporting logic must know which one each station uses. Read both variables after boot and store them.

A complete session: cable plugged in first (E02)

Station configured with TxStartPoint = EVConnected,Authorized. The driver plugs in, so the transaction starts immediately with triggerReason CablePluggedIn:

json
[2, "te-0001", "TransactionEvent", {
  "eventType": "Started",
  "timestamp": "2026-10-26T10:00:00.000Z",
  "triggerReason": "CablePluggedIn",
  "seqNo": 0,
  "transactionInfo": {
    "transactionId": "7f3c2a10-5e61-4b8e-9d2a-0c4b1e8f6a33",
    "chargingState": "EVConnected"
  },
  "evse": {
    "id": 1,
    "connectorId": 1
  },
  "meterValue": [
    {
      "timestamp": "2026-10-26T10:00:00.000Z",
      "sampledValue": [
        {
          "value": 1250345.0,
          "context": "Transaction.Begin",
          "measurand": "Energy.Active.Import.Register",
          "unitOfMeasure": { "unit": "Wh" }
        }
      ]
    }
  ]
}]

[3, "te-0001", {}]

The driver taps an RFID card. The station authorizes it (with Authorize, its cache or local list, see part 5) and reports it in an Updated event with triggerReason Authorized and the idToken. The CSMS answers with idTokenInfo, which is how it can still refuse the token at this point.

json
[2, "te-0002", "TransactionEvent", {
  "eventType": "Updated",
  "timestamp": "2026-10-26T10:00:20.000Z",
  "triggerReason": "Authorized",
  "seqNo": 1,
  "transactionInfo": {
    "transactionId": "7f3c2a10-5e61-4b8e-9d2a-0c4b1e8f6a33",
    "chargingState": "EVConnected"
  },
  "idToken": {
    "idToken": "04A2B3C4D5E680",
    "type": "ISO14443"
  }
}]

[3, "te-0002", {
  "idTokenInfo": {
    "status": "Accepted"
  }
}]

Energy starts flowing: an Updated with triggerReason ChargingStateChanged and chargingState Charging. Then periodic meter values arrive as Updated events with triggerReason MeterValuePeriodic, carrying the measurands configured in SampledDataCtrlr.TxUpdatedMeasurands every TxUpdatedInterval seconds:

json
[2, "te-0004", "TransactionEvent", {
  "eventType": "Updated",
  "timestamp": "2026-10-26T10:01:30.000Z",
  "triggerReason": "MeterValuePeriodic",
  "seqNo": 3,
  "transactionInfo": {
    "transactionId": "7f3c2a10-5e61-4b8e-9d2a-0c4b1e8f6a33",
    "chargingState": "Charging"
  },
  "meterValue": [
    {
      "timestamp": "2026-10-26T10:01:30.000Z",
      "sampledValue": [
        {
          "value": 1251890.0,
          "context": "Sample.Periodic",
          "measurand": "Energy.Active.Import.Register",
          "unitOfMeasure": { "unit": "Wh" }
        },
        {
          "value": 61400.0,
          "context": "Sample.Periodic",
          "measurand": "Power.Active.Import",
          "unitOfMeasure": { "unit": "W" }
        }
      ]
    }
  ]
}]

Unlike 1.6, value is a JSON number, not a string, and units come from unitOfMeasure (with an optional power-of-ten multiplier). Default unit for energy is Wh. If you need the 1.6 background on measurands and contexts, see MeterValues in 1.6; the concepts carry over.

Finally the driver taps the card again to stop. Because Authorized is in TxStopPoint, the transaction ends, with triggerReason StopAuthorized and stoppedReason Local:

json
[2, "te-0009", "TransactionEvent", {
  "eventType": "Ended",
  "timestamp": "2026-10-26T10:42:05.000Z",
  "triggerReason": "StopAuthorized",
  "seqNo": 8,
  "transactionInfo": {
    "transactionId": "7f3c2a10-5e61-4b8e-9d2a-0c4b1e8f6a33",
    "chargingState": "EVConnected",
    "stoppedReason": "Local"
  },
  "idToken": {
    "idToken": "04A2B3C4D5E680",
    "type": "ISO14443"
  },
  "meterValue": [
    {
      "timestamp": "2026-10-26T10:42:05.000Z",
      "sampledValue": [
        {
          "value": 1288712.0,
          "context": "Transaction.End",
          "measurand": "Energy.Active.Import.Register",
          "unitOfMeasure": { "unit": "Wh" }
        }
      ]
    }
  ]
}]

The Ended event may also carry the meter values configured in SampledDataCtrlr.TxEndedMeasurands, sampled every TxEndedInterval across the whole transaction. That is how a station can deliver a complete energy profile at the end even if it was offline for part of the session.

triggerReason and stoppedReason

triggerReason (TriggerReasonEnumType) says why this event was sent. stoppedReason (ReasonEnumType) appears in Ended and says why the transaction ended. They are different enums and are often confused.

triggerReasonTypical use
CablePluggedIn, EVDetected, EVDepartedPhysical events (plug, parking sensor)
Authorized, Deauthorized, StopAuthorizedAuthorization started, was revoked, or a stop was authorized
RemoteStart, RemoteStopResult of RequestStartTransaction / RequestStopTransaction
ChargingStateChangedchargingState changed (Charging, SuspendedEV, SuspendedEVSE, EVConnected, Idle)
MeterValuePeriodic, MeterValueClockSampled or clock-aligned meter values
EVCommunicationLost, EVConnectTimeoutCable removed or EV never connected within EVConnectionTimeOut
EnergyLimitReached, TimeLimitReached, ChargingRateChangedLimits and smart charging effects
AbnormalCondition, ResetCommand, UnlockCommand, Trigger, SignedDataReceivedFaults, commands, TriggerMessage, signed meter data

stoppedReason values include Local, Remote, EVDisconnected, DeAuthorized, EmergencyStop, EnergyLimitReached, GroundFault, ImmediateReset, LocalOutOfCredit, MasterPass, OvercurrentFault, PowerLoss, PowerQuality, Reboot, SOCLimitReached, StoppedByEV, TimeLimitReached, Timeout and Other. When stoppedReason is omitted the default is Local. Map these to your session-end reporting; they are what support teams need when a driver complains a session stopped early.

seqNo, offline and message delivery

Transaction messages are the ones the station must not lose. While offline, the station queues TransactionEvent messages and delivers them in order after reconnecting, with offline: true on events that happened while it was disconnected (use cases E04 and E12). Retries for transaction messages that fail or get a CALLERROR are governed by OCPPCommCtrlr.MessageAttempts and MessageAttemptInterval, both with instance TransactionEvent (E13).

seqNo lets the CSMS detect gaps and reorder. Do not reject an event because a previous seqNo is missing; store it and flag the transaction as incomplete until the gap fills or the transaction ends. GetTransactionStatus (E14) asks the station whether a transaction is still ongoing and whether it still has queued messages:

json
[2, "gts-17", "GetTransactionStatus", {
  "transactionId": "7f3c2a10-5e61-4b8e-9d2a-0c4b1e8f6a33"
}]

[3, "gts-17", {
  "ongoingIndicator": false,
  "messagesInQueue": true
}]

messagesInQueue: true means do not finalise the bill yet. This is the cleanest way to decide when a session with gaps can be closed for billing and for OCPI CDRs.

CSMS implementation checklist

  • Idempotency: stations retry. Deduplicate on (station, transactionId, seqNo) and return the same response.
  • Always respond: never return a CALLERROR for business reasons such as an unknown token or a closed account. Respond normally and use idTokenInfo.status to deauthorize. A CALLERROR makes the station retry the same message.
  • Unknown transaction: an Updated or Ended for a transaction you have never seen (the Started is still queued or was lost) should create a placeholder, not fail.
  • Energy from registers: compute energy as the difference of Energy.Active.Import.Register values, not by summing power samples.
  • Time: use event timestamp for billing, not receive time. Offline events can arrive hours later.
  • Remote starts: correlate with transactionInfo.remoteStartId, which echoes the remoteStartId you sent in RequestStartTransaction.

Key takeaways

  • One message, three event types; the station owns the string transactionId.
  • TxStartPoint and TxStopPoint define what a transaction even is on each station. Read them.
  • Use seqNo, offline and GetTransactionStatus to handle gaps instead of rejecting messages.
  • Respond to every TransactionEvent; deauthorize via idTokenInfo, never via CALLERROR.

Next: authorization in 2.0.1. For the 1.6 model this replaces, see Start/StopTransaction in 1.6.

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.