OCPP 1.6 vs 2.0.1: designing one CSMS for both
OCPP 1.6 and 2.0.1 are not compatible, yet most networks run both. How one charging backend can serve both versions without two billing systems.
By Ayondip Jana · · 11 min read
Short answer
OCPP 2.0.1 is a redesign of 1.6, and the two cannot talk to each other. A CSMS can serve both by reading the version each charger offers in the WebSocket handshake, sending it to a version-specific adapter, and keeping sessions, billing and status history in one model that neither adapter owns.
Take a small site with two DC chargers. Charger A runs OCPP 1.6 firmware. Charger B was installed last month with OCPP 2.0.1. A driver charges on A on Monday and on B on Tuesday, pays from the same wallet both times, and expects one receipt history.
The Open Charge Alliance says the two protocol versions are not compatible, so charger A cannot speak to a backend that only knows 2.0.1, and the reverse is also true. Firmware for 2.0.1 ships model by model and some older units never get it. In the US, NEVI-funded sites must conform to 2.0.1 under 23 CFR 680.108, while the rest of a fleet often stays on 1.6. So “both at once” is the normal state of a charging network for years.
This post explains how a charge point management system (CSMS) can serve both versions from one codebase. We’ll follow charger A, charger B and the driver through every step.
What actually differs between the two versions
The Open Charge Alliance’s own summary, What is new in OCPP 2.0.1, lists the headline changes: a device model for configuration, three defined security profiles, more token types, and a single TransactionEvent message that replaces StartTransaction, StopTransaction and the transaction-related MeterValues and StatusNotification messages.
The headline list hides the details that hurt. Comparing the official JSON schemas field by field, these are the ones that decide how your backend stores data:
| Charger A (1.6) | Charger B (2.0.1) | |
|---|---|---|
| Who creates the transaction ID | The CSMS, as an integer | The charger, as a string of up to 36 characters |
| Session messages | Start, meter values, stop | One TransactionEvent: Started, Updated, Ended |
| Message ordering | Nothing built in | seqNo on every event, plus an offline flag |
| Meter reading | A string | A number |
| Connector status | 9 values, such as Charging and Finishing |
5 values, such as Occupied |
| Driver identity | idTag, a bare string up to 20 characters |
idToken with a type: ISO14443, Central, eMAID and others |
Each row is a place where code written for one version quietly does the wrong thing with the other.
Three ways to serve a mixed fleet
Option 1: run two backends
Keep the existing 1.6 CSMS and stand up a new one for 2.0.1. It is the fastest way to get charger B online, and it is how many networks start.
The problem appears on Tuesday. The driver’s wallet, tariffs and session history now live in two systems, and something has to keep them in sync. Every report, from revenue to uptime, has to merge two databases with two different ideas of a “session”.
Option 2: translate 1.6 into 2.0.1 at the edge
Put a proxy in front of a 2.0.1-only backend that rewrites charger A’s messages into 2.0.1. One backend, one data model.
This works until the translation has to invent data. Charger A sends no seqNo, no token type and no offline flag, so the proxy must make them up. Status goes from nine values to five, which is fine in that direction, but commands going back the other way have no 1.6 equivalent for some 2.0.1 features. The proxy ends up holding protocol logic and guesses that nobody can see from the backend.
Option 3: one adapter per version, one domain model
Each charger connects to the same server. The server sends it to a 1.6 adapter or a 2.0.1 adapter, and both adapters write to one shared domain model that knows nothing about OCPP versions.
The adapters are thin. They know their own protocol and translate it into a handful of domain events: session started, meter reading, session ended, port status changed. Everything that earns money or gets audited sits below them. This is the design the rest of the post walks through.
Step 1: let the handshake pick the adapter
OCPP-J runs over WebSocket, and the version is agreed when the connection opens. The charger lists the versions it supports in the Sec-WebSocket-Protocol header, and the server answers with one. Charger A offers ocpp1.6. Charger B offers ocpp2.0.1.
In Python with the websockets library, the whole routing decision fits in a few lines:
ADAPTERS = {"ocpp1.6": Adapter16, "ocpp2.0.1": Adapter201}
server = await websockets.serve(on_connect, "0.0.0.0", 9000,
subprotocols=["ocpp2.0.1", "ocpp1.6"])
async def on_connect(ws):
adapter = ADAPTERS[ws.subprotocol](charger_id_from(ws), ws)
await adapter.start()
Two details are worth knowing. The order of subprotocols is a policy decision: websockets picks the first entry in the server’s list that the charger also offered, so a charger whose firmware offers both versions gets 2.0.1 here. And in websockets 15, a charger that offers an unsupported version, or no version at all, is refused with HTTP 400 before on_connect runs. We expected to write that guard ourselves and found it already handled when we tested it. Older releases behaved differently, so check the version you pin.
Step 2: give every session your own ID
Charger A starts a session. In 1.6 the CSMS must reply with a transaction ID, an integer, say 4182. Charger B starts a session and tells the CSMS its ID, a string such as "f3c1d2e4-7a9b-...".
If the sessions table uses the protocol’s transaction ID as its key, it can only hold one of these. So the domain model creates its own ID for every session and stores the protocol’s ID next to it:
| session_id | charger | version | protocol_tx_id | token_type |
|---|---|---|---|---|
s-01 |
A | 1.6 | 4182 |
ISO14443 |
s-02 |
B | 2.0.1 | f3c1d2e4-... |
Central |
Lookups from either adapter go through (charger, protocol_tx_id), stored as a string. Billing, receipts and the driver’s history only ever see session_id. When charger A is upgraded to 2.0.1 next year, its old sessions keep their integer IDs, its new ones get strings, and nothing above the adapters notices.
Step 3: map two status vocabularies to one
Mid-session, charger A reports Charging. Charger B, in the same situation, reports Occupied. 1.6 has nine connector statuses because it mixes connector state with session state. 2.0.1 has five because session state moved into TransactionEvent.
The domain keeps one set of five port states, and the 1.6 adapter folds its extra values into them:
STATUS_16 = {
"Available": "available", "Reserved": "reserved",
"Unavailable": "unavailable", "Faulted": "faulted",
# 1.6 session-phase statuses all mean the port is in use
"Preparing": "occupied", "Charging": "occupied", "SuspendedEV": "occupied",
"SuspendedEVSE": "occupied", "Finishing": "occupied",
}
The 1.6 detail is not thrown away. Whether the car paused charging (SuspendedEV) belongs on the session, which is where 2.0.1 puts it. The port history stays in the same five states for every charger, and that is what uptime reporting needs later.
Step 4: don’t bill until the sequence is complete
Charger B loses its connection halfway through the driver’s session. It keeps charging, queues its events, and sends them when it reconnects: Started (seqNo 0), then Updated (2), then Ended (3). Event 1 is still in flight.
2.0.1 gives the backend what it needs to notice. The schema describes seqNo as a way to check that all messages of a transaction have been received, and events recorded while offline carry offline: true. The 2.0.1 adapter tracks the last number it saw per session:
if session.last_seq is not None and seq_no != session.last_seq + 1:
session.mark_incomplete(expected=session.last_seq + 1, got=seq_no)
session.last_seq = seq_no
An incomplete session waits before the final bill is issued, or goes to review. Charger A gives you none of this. 1.6 has no sequence number, so for 1.6 sessions the backend falls back to timestamps and the stop message’s final meter reading.
The adapters differ on the input in small ways too. A 1.6 meter value is a string in the schema and a 2.0.1 value is a number, so the 1.6 adapter converts before anything reaches the domain.
Step 5: keep the token type from day one
The driver taps an RFID card on charger A and starts from the app on charger B. Charger A sends an idTag, a bare string with no type. Charger B sends an idToken with type: "Central" for the app start, or ISO14443 for a card.
Store the type on every token from the first day. For 1.6 chargers, decide it from what you issued: if the string matches a card number in your system, record it as a card. Then one driver account holds both kinds of token, and the wallet doesn’t care which charger was used.
What changes next, and where it lands
Sessions and status are only the start. A few messages most teams wire up next change shape between versions. In this design each change stays inside one adapter:
- Boot. 2.0.1 adds a
reason(PowerUp,FirmwareUpdate,Watchdogand others). A watchdog reboot and a planned firmware update should not look the same in an outage log, so the 2.0.1 adapter passes the reason down. - Remote start. 2.0.1
RequestStartTransactionrequires aremoteStartId, and the charger can echo it in itsTransactionEvent. An app tap can then be tied to the exact session it started, without matching on connector and time. - Stop reasons. 1.6 has 11 stop reasons. The 2.0.1 list adds ones like
LocalOutOfCredit,EnergyLimitReachedandSOCLimitReached, which line up with prepaid wallets and session limits. - Configuration. 1.6 uses vendor-specific keys; 2.0.1 uses variables on components. Keep a short list of the settings operators really change and map each per charger model.
What we got wrong first
We built a small version of this design in Python and tested it with one simulated charger of each version before writing this post. The test ran a full session on each and deliberately dropped one 2.0.1 event. Three things surprised us:
- We wrote a guard for chargers that offer no matching version, and it never ran.
websockets15 refuses those connections itself with HTTP 400. - Our first 1.6 meter handler stored strings, because 1.6 sends readings as strings. Totals would have concatenated instead of added. The fix is one
float()in the adapter, and the domain should never have to know. - We first recorded every 1.6
idTagas an RFID card. That holds only if the fleet never accepts app or key-code starts on 1.6 chargers, so the type now comes from our own token records.
None of these are hard problems. Each one is a protocol detail that would have leaked into billing if the adapters weren’t the only code allowed to see raw messages.
Uptime reporting across both versions
Both main regulators measure reliability from status data. Under 23 CFR 680.116, NEVI uptime is calculated per port each month over the previous twelve months, with some outages outside the operator’s control, such as grid failures, excluded. The UK Public Charge Point Regulations 2023 require rapid charge point networks to be 99% reliable over a calendar year.
The port history from Step 3 already has one vocabulary for every charger, so a single query covers the fleet. Two more habits make the numbers defensible: record the cause of an outage when it happens, and keep the raw OCPP messages so any figure can be traced back to what a charger sent.
Takeaways
- Keep the protocol at the edge. Adapters translate; the domain decides. Billing should never see a raw OCPP message.
- Own your identifiers. Session IDs, port states and token types should be yours, with the protocol’s values stored alongside.
- Use what 2.0.1 gives you, and fall back sensibly for 1.6.
seqNo,offlineand typed tokens make billing safer, but a mixed fleet needs a defined fallback for chargers that don’t send them.
This also leaves room for OCPP 2.1, which the Open Charge Alliance released in 2025 as an extension of 2.0.1 with bidirectional charging and energy management. In this design it is one more adapter.
On the OCPP platform our team built, prepaid wallet billing and peak and off-peak tariffs run in the backend, and sessions end on their own when the driver’s limit or balance is reached. That is the logic you least want to write twice.
FAQ
Is OCPP 2.0.1 backward compatible with 1.6?
No. The Open Charge Alliance states that the two versions are not compatible, so a backend serving a mixed fleet has to implement both.
Can one CSMS serve OCPP 1.6 and 2.0.1 chargers on the same port?
Yes. The version is agreed per connection through the Sec-WebSocket-Protocol header, so one server can route each charger to the right adapter.
Which version does a charger get if it supports both?
The server chooses. With Python’s websockets library, it is the first entry in the server’s subprotocols list that the charger also offered.
Why does a 2.0.1 charger report Occupied instead of Charging?
The 2.0.1 StatusNotification reports only connector availability, with five values. Charging state comes from TransactionEvent instead.
How long does a migration from OCPP 1.6 to 2.0.1 take?
Codibly estimates 6 to 12 months for a mid-size network, most of it in phased firmware rollout. Chargers that cannot take 2.0.1 firmware stay on 1.6 until they are replaced.
Does NEVI require OCPP 2.0.1?
Yes. 23 CFR 680.108 requires NEVI-funded chargers to conform to OCPP 2.0.1. The UK regulations do not name an OCPP version.
The full migration plan, from fleet audit to phased rollout, is on our OCPP 2.0.1 migration page.