If you have ever plugged in an electric car at a public charger, two computers had a conversation you never saw: the charger (the charge point) and a server that manages it (the CSMS, Charging Station Management System). The language they speak is usually OCPP, the Open Charge Point Protocol.
This tutorial walks through that conversation hands-on with OCPP 1.6, the version most chargers in the field still run. You will start a CSMS on your laptop, connect a simulated charger, run a charging session from the dashboard, and then drive the same charger from the command line with curl. No hardware needed.
What OCPP is, in five minutes
OCPP is an open standard from the Open Charge Alliance. Version 1.6 (2015) is the workhorse of the industry. Versions 2.0.1 and 2.1 add more features, but the core ideas are the same.
Three ideas carry you a long way.
1. The charger connects to the server, not the other way around. A charge point opens a WebSocket to the CSMS and keeps it open. The URL ends with the charger's identity, for example:
ws://csms.example.com/ocpp/CS-0001
During the WebSocket handshake the charger asks for the ocpp1.6 subprotocol, so the server knows which version it speaks. Because the connection stays open, the server can send commands to a charger that sits behind a mobile network or a firewall.
2. Every message is a small JSON array. OCPP 1.6 over WebSocket (often called OCPP-J) has three message types:
| Type | Meaning | Shape |
|---|---|---|
| 2 | CALL, a request | [2, "<messageId>", "<Action>", {payload}] |
| 3 | CALLRESULT, a successful answer | [3, "<messageId>", {payload}] |
| 4 | CALLERROR, a failed answer | [4, "<messageId>", "<errorCode>", "<description>", {details}] |
The messageId ties an answer to its request. Either side can send a CALL: the charger reports things (it booted, a cable was plugged in, energy was delivered), and the server sends commands (start, stop, reset, change a setting).
3. A charger has connectors, and each connector has a status. Connector 1 is the first plug. Connector 0 means the charger as a whole. Statuses move through a small state machine:
Available -> Preparing -> Charging -> Finishing -> Available
with side states such as SuspendedEV, Unavailable and Faulted.
The messages you will see in this tutorial:
| Message | Sent by | Purpose |
|---|---|---|
| BootNotification | Charger | "I just started. Here is my vendor and model." |
| Heartbeat | Charger | "I am still here." Also syncs the clock. |
| StatusNotification | Charger | A connector changed status. |
| Authorize | Charger | "May this RFID tag (idTag) charge?" |
| StartTransaction | Charger | Charging started. The server answers with a transaction id. |
| MeterValues | Charger | Energy readings during the session. |
| StopTransaction | Charger | Charging stopped, with the final meter reading. |
| RemoteStartTransaction | Server | Start charging for an idTag, for example from an app. |
| RemoteStopTransaction | Server | Stop a transaction. |
| TriggerMessage, GetConfiguration | Server | Ask the charger to send a message now, or read its settings. |
The lab
You need a CSMS to talk to and a charger to talk to it. For this tutorial I use EVtivity, a self-hosted CSMS that ships with a built-in charging station simulator, so the whole lab runs in Docker on one machine. Any CSMS and simulator would teach the same protocol; this one just saves setup.
You need Docker with Docker Compose, plus curl and jq for the last part.
git clone https://github.com/evtivity/evtivity-csms.git
cd evtivity-csms
docker compose up -d
Give it about a minute to start, then open the dashboard at http://localhost:7100 and sign in as admin@evtivity.local with the password admin123.
The stack starts two simulated chargers. CS-0001 speaks OCPP 1.6 and CS-0002 speaks OCPP 2.1. We will use CS-0001, which connects to ws://localhost:7103/CS-0001.
Step 1: BootNotification, and why a new charger waits
Open Stations, then CS-0001, and go to the OCPP Commands tab. At the bottom is the OCPP message log: every message between the charger and the server, newest first. Click a row to see its JSON.
The first thing a charger does after connecting is introduce itself:
[2, "a1b2c3", "BootNotification", {
"chargePointVendor": "...",
"chargePointModel": "...",
"chargePointSerialNumber": "...",
"firmwareVersion": "..."
}]
The server answers with one of three statuses:
-
Accepted: the charger is registered and may operate. -
Pending: the server knows about it but has not decided yet. The charger must not start normal operation and should retry afterintervalseconds. -
Rejected: go away, retry later.
A fresh install answers Pending, because new chargers need an operator's approval first:
[3, "a1b2c3", { "status": "Pending", "currentTime": "2026-10-17T09:00:00Z", "interval": 300 }]
This is a real-world pattern. A CSMS on the internet should not trust every device that knows its URL.
Click Approve on the station page. On its next BootNotification the charger receives Accepted, and the log fills up:
[3, "d4e5f6", { "status": "Accepted", "currentTime": "2026-10-17T09:05:00Z", "interval": 300 }]
Two things follow:
-
StatusNotification, once for connector 0 (the charger) and once per connector:
{"connectorId": 1, "errorCode": "NoError", "status": "Available"}. -
Heartbeat every
intervalseconds (300 here). The empty request{}gets back{"currentTime": "..."}, which also keeps the charger's clock in sync.
The server chose the heartbeat interval, not the charger. The CSMS tells chargers how to behave, and OCPP gives it the messages to do so.
Step 2: Create an RFID tag
Drivers identify themselves with an idTag, usually the id of an RFID card, and the charger asks the server whether that tag may charge. An unknown tag is refused, so create one first.
Open Tokens and add a token with:
-
ID:
TUTORIAL-001 - Type: RFID (ISO14443)
-
Driver: the demo driver,
driver@evtivity.local
Keep idTags at 20 characters or fewer. OCPP 1.6 limits them to 20.
Step 3: A charging session, message by message
Back on CS-0001, open the Simulate tab. It acts as the car and the driver. Enter TUTORIAL-001 as the ID token and click through the steps, checking the message log after each one.
Plug in. The charger notices a cable:
[2, "...", "StatusNotification", { "connectorId": 1, "errorCode": "NoError", "status": "Preparing" }]
Start charging (the driver taps the card). Three things happen in order.
First the charger asks whether the tag is allowed:
[2, "...", "Authorize", { "idTag": "TUTORIAL-001" }]
[3, "...", { "idTagInfo": { "status": "Accepted" } }]
Then it starts a transaction and reports its meter reading in Wh:
[2, "...", "StartTransaction", { "connectorId": 1, "idTag": "TUTORIAL-001", "meterStart": 0, "timestamp": "..." }]
[3, "...", { "transactionId": 42, "idTagInfo": { "status": "Accepted" } }]
The server assigns the transactionId. From here on, both sides use it to refer to this session.
Finally the connector status changes to Charging.
While charging, the charger sends MeterValues every few seconds (the MeterValueSampleInterval setting), each tagged with the transaction id:
[2, "...", "MeterValues", {
"connectorId": 1,
"transactionId": 42,
"meterValue": [{ "timestamp": "...", "sampledValue": [{ "value": "1520", "measurand": "Energy.Active.Import.Register", "unit": "Wh" }] }]
}]
The Sessions page in the dashboard shows the energy growing. That number comes straight from these messages.
Stop charging. The charger closes the transaction with its final meter reading:
[2, "...", "StopTransaction", { "transactionId": 42, "meterStop": 3150, "timestamp": "...", "reason": "Local" }]
The connector then moves to Finishing. Unplug returns it to Available.
The energy for the session is meterStop - meterStart. Billing systems are built on this subtraction, which is why chargers report a cumulative meter register rather than the energy of each session.
Step 4: The same thing from the command line
Everything the dashboard does goes through a REST API, so you can drive the charger with curl. The API runs on port 7102.
Get an access token. It is valid for an hour:
TOKEN=$(curl -s localhost:7102/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@evtivity.local","password":"admin123"}' | jq -r .token)
AUTH="Authorization: Bearer $TOKEN"
Look at the charger:
curl -s "localhost:7102/v1/stations?search=CS-0001" -H "$AUTH" \
| jq '.data[] | {id, stationId, ocppProtocol, isOnline, status}'
Note the id field (something like sta_000000000001). The station URLs below use it.
Send a TriggerMessage. This asks the charger to send a message now instead of waiting:
curl -s localhost:7102/v1/ocpp/commands/v16/TriggerMessage -H "$AUTH" \
-H 'Content-Type: application/json' \
-d '{"stationId":"CS-0001","requestedMessage":"Heartbeat"}' | jq
The response field holds the charger's own answer (Accepted), and a fresh Heartbeat appears in the log.
Read a configuration key. In OCPP 1.6, chargers keep their settings as key-value pairs:
curl -s localhost:7102/v1/ocpp/commands/v16/GetConfiguration -H "$AUTH" \
-H 'Content-Type: application/json' \
-d '{"stationId":"CS-0001","key":["HeartbeatInterval","MeterValueSampleInterval"]}' | jq .response
Start a session remotely, the way a mobile app does. First plug the simulated car in:
curl -s localhost:7102/v1/css/actions/plugIn -H "$AUTH" \
-H 'Content-Type: application/json' \
-d '{"stationId":"CS-0001","evseId":1}'
Then send RemoteStartTransaction:
curl -s localhost:7102/v1/ocpp/commands/v16/RemoteStartTransaction -H "$AUTH" \
-H 'Content-Type: application/json' \
-d '{"stationId":"CS-0001","connectorId":1,"idTag":"TUTORIAL-001"}' | jq .response
Watch the log. The server sends a CALL, the charger answers {"status": "Accepted"}, and then the charger itself sends StartTransaction, just like in Step 3. An accepted RemoteStartTransaction only means "I will try". The session exists once StartTransaction arrives.
Find the transaction and stop it:
TX=$(curl -s "localhost:7102/v1/sessions?status=active" -H "$AUTH" \
| jq -r '.data[] | select(.stationName=="CS-0001") | .transactionId')
curl -s localhost:7102/v1/ocpp/commands/v16/RemoteStopTransaction -H "$AUTH" \
-H 'Content-Type: application/json' \
-d "{\"stationId\":\"CS-0001\",\"transactionId\":$TX}" | jq .response
The charger accepts, then sends StopTransaction with "reason": "Remote".
Read the message log from the API:
STATION_ID=sta_000000000001 # the id from the stations call above
curl -s "localhost:7102/v1/stations/$STATION_ID/ocpp-logs?limit=10" -H "$AUTH" \
| jq '.data[] | {createdAt, direction, action, messageType}'
messageType is the same 2, 3 or 4 from the table at the top.
What you have learned
- A charger opens a WebSocket to the CSMS and keeps it open. The URL carries its identity, and the subprotocol carries the OCPP version.
- Every message is a CALL, a CALLRESULT or a CALLERROR, matched by message id.
- BootNotification registers a charger, and the server can hold it at
Pending. - A session is Authorize, StartTransaction, MeterValues and StopTransaction. The server-issued transaction id ties them together.
- Remote commands only ask. The charger's own messages report what actually happened.
The OCPP 1.6 specification is free to download from the Open Charge Alliance and reads well once these basics are in place.
This article was written with the help of AI and reviewed by the author.

Top comments (0)