Skip to content
OCPP 1.6-J message examples for a charging management systemMobility
Mobility

OCPP 1.6-J by Example: The Eight Messages a CSMS Has to Get Right, With Real Payloads

RRRavi Rai··13 min read

Every OCPP tutorial explains the message flow in prose and then stops exactly where you need it to keep going: what the bytes actually look like. This is the other half. Eight messages, the real JSON for each, the fields that matter for billing and the ones that are safe to ignore, and the mistake we made on each one the first time. It is the reference we wished existed when we built PlugEV, an OCPP 1.6-J charging management system that runs live on 40-plus chargers across two Indian cities.

If you want the narrative version first, our OCPP implementation guide covers the decisions: build or buy, 1.6-J or 2.0.1, the transport layer, the stack. This post assumes you have made them and are now looking at an empty WebSocket handler.

Before the eight: how every message is framed

OCPP 1.6-J is JSON over a WebSocket, and every message on the wire is a JSON array whose first element says what kind of message it is. There are exactly three kinds. A charger opens the socket to a URL that ends in its own identity, and asks for the subprotocol ocpp1.6. If your server does not echo that subprotocol back in the handshake, most chargers will close the connection and try again forever, and nothing in your logs will say why.

textwss://csms.example.in/ocpp/CP-NOIDA-0042
Sec-WebSocket-Protocol: ocpp1.6

A CALL is a request. A CALLRESULT is the successful answer to a CALL, matched by the unique id. A CALLERROR is the unsuccessful one. The unique id is a string the sender chooses, and the receiver must echo it back exactly. That is the whole protocol at the framing level, and it is symmetric: chargers call the CSMS and the CSMS calls chargers, with the same three shapes.

json[
  2,
  "19223201",
  "Heartbeat",
  {}
]

[
  3,
  "19223201",
  {
    "currentTime": "2026-09-16T07:41:12Z"
  }
]

[
  4,
  "19223201",
  "NotImplemented",
  "Unknown action",
  {}
]

The error codes are a fixed list: NotImplemented, NotSupported, InternalError, ProtocolError, SecurityError, FormationViolation, PropertyConstraintViolation, OccurenceConstraintViolation, TypeConstraintViolation and GenericError. Yes, OccurenceConstraintViolation is misspelled in the specification, and a charger will reject the correctly spelled one. Copy it exactly.

1. BootNotification: the charger introduces itself

The first thing a charger sends after the socket opens. It tells you what it is. Your answer tells it whether it is welcome, what time it is, and how often to send a Heartbeat.

json[
  2,
  "1001",
  "BootNotification",
  {
    "chargePointVendor": "Delta",
    "chargePointModel": "AC Mini Plus",
    "chargePointSerialNumber": "DEL-AC-2291",
    "firmwareVersion": "1.4.11",
    "iccid": "8991000012345678901",
    "imsi": "404100012345678"
  }
]

[
  3,
  "1001",
  {
    "status": "Accepted",
    "currentTime": "2026-09-16T07:41:12Z",
    "interval": 300
  }
]
  • Only vendor and model are required. Serial, firmware, ICCID and IMSI are optional and a surprising number of chargers omit them. Do not make your handler crash on a missing serial.
  • The interval is in seconds and it is yours to choose. 300 is a sensible production value. Anything under 60 on a network with a hundred chargers is a self-inflicted load test.
  • Status Pending means come back later. A charger that gets Pending will retry BootNotification at the interval you gave it and send nothing else until it is Accepted. Use it for chargers you have not yet approved rather than Rejected, which some firmware treats as permanent.
  • currentTime is how the charger sets its clock. A charger with no working clock uses this for every timestamp it sends you afterwards, including the ones you bill from. Send it in UTC with the Z.

What we got wrong: we treated a second BootNotification on an already-open socket as an error. It is not. A charger sends it again after a firmware update, after a reset, and on some models after a long enough network drop, and if you refuse it the charger goes quiet.

2. Heartbeat: proof of life, and the clock

json[
  2,
  "1002",
  "Heartbeat",
  {}
]

[
  3,
  "1002",
  {
    "currentTime": "2026-09-16T07:46:12Z"
  }
]

Empty request, one-field response. The charger sends it every interval seconds when nothing else is happening. Two things it is for. It tells you the charger is still there, so your dashboard can show a charger as offline when three intervals pass without one. And it resyncs the charger's clock, which matters more than it sounds: a charger whose clock has drifted twenty minutes will stamp a StopTransaction twenty minutes wrong, and that is a billing dispute.

What we got wrong: we counted a charger as online only on Heartbeat. Any message counts. A charger in the middle of a busy session sends MeterValues every minute and may not send a Heartbeat for an hour, and we marked it offline while it was charging a car.

3. StatusNotification: what each connector is doing

json[
  2,
  "1003",
  "StatusNotification",
  {
    "connectorId": 1,
    "errorCode": "NoError",
    "status": "Preparing",
    "timestamp": "2026-09-16T07:52:03Z"
  }
]

[
  3,
  "1003",
  {}
]

One per connector, sent whenever the state changes. Connector 0 is the charger as a whole; connectors 1 and up are the sockets. The status values, in the order a normal session moves through them: Available, Preparing, Charging, SuspendedEV or SuspendedEVSE, Finishing, and back to Available. Reserved, Unavailable and Faulted are the off-path ones.

  • SuspendedEV is the car pausing. Battery full, or battery management taking a breather. This is the signal an idle-fee timer starts on, as we cover in the tariff post. SuspendedEVSE is the charger pausing, for load management or a fault, and the driver should not pay for that.
  • errorCode is separate from status. A connector can be Faulted with errorCode GroundFailure, or Available with errorCode NoError. Log both. The error codes are a fixed list too, and the useful ones in practice are GroundFailure, OverCurrentFailure, PowerMeterFailure and WeakSignal.
  • Preparing does not mean charging has started. It means a cable is plugged in or a card was tapped. Charging starts at StartTransaction, not here. We showed Preparing as a live session on the dashboard for a week before an operator asked why his revenue did not match.

4. Authorize: may this tag charge?

json[
  2,
  "1004",
  "Authorize",
  {
    "idTag": "04A2B3C4D5E680"
  }
]

[
  3,
  "1004",
  {
    "idTagInfo": {
      "status": "Accepted",
      "expiryDate": "2026-12-31T23:59:59Z"
    }
  }
]

The idTag is whatever identified the driver: an RFID card UID, a token your app generated, a code from a QR flow. The charger asks you, you answer Accepted, Blocked, Expired, Invalid or ConcurrentTx. ConcurrentTx means this tag already has a session running somewhere and your policy does not allow two at once.

The field most people miss is parentIdTag. Return one and the charger will let any tag with the same parent stop the transaction that this tag started. That is how a fleet manager's card can end a session a driver's card began. Without it, only the exact tag that started a session can stop it, and a lost card means a stuck session.

What we got wrong: we answered Authorize slowly, because it hit a database and a third-party API. Chargers time out on Authorize in a few seconds and treat the timeout as Invalid. Cache the answer, answer from cache, refresh in the background. Our RFID guide goes deeper on the tag side.

5. StartTransaction: the meter reading that starts the bill

json[
  2,
  "1005",
  "StartTransaction",
  {
    "connectorId": 1,
    "idTag": "04A2B3C4D5E680",
    "meterStart": 1284512,
    "timestamp": "2026-09-16T07:52:41Z"
  }
]

[
  3,
  "1005",
  {
    "transactionId": 88213,
    "idTagInfo": {
      "status": "Accepted"
    }
  }
]
  • meterStart is in watt-hours, always, and it is cumulative. It is the charger's lifetime energy register at the moment the session began. 1284512 means the charger has delivered 1,284.5 kWh in its life. You will subtract this from meterStop later, and that subtraction is the bill.
  • transactionId is yours to assign, and it must be an integer. The charger will quote it back on every MeterValues and on StopTransaction. Make it unique across your whole network, not per charger, or two chargers will hand you the same id on the same day.
  • The charger may send this while offline and deliver it later. The timestamp is when it happened, not when you received it. Store both. Your bill runs off the charger's timestamp; your fraud checks run off yours.

What we got wrong: we answered with a transactionId and forgot to persist the row before the response went out. A crash between the two left a charger holding a transaction id we had no record of, and its StopTransaction an hour later referred to a session that did not exist. Write, then reply.

6. MeterValues: the samples during a session

json[
  2,
  "1006",
  "MeterValues",
  {
    "connectorId": 1,
    "transactionId": 88213,
    "meterValue": [
      {
        "timestamp": "2026-09-16T08:02:41Z",
        "sampledValue": [
          {
            "value": "1286004",
            "measurand": "Energy.Active.Import.Register",
            "unit": "Wh",
            "context": "Sample.Periodic"
          },
          {
            "value": "7.2",
            "measurand": "Power.Active.Import",
            "unit": "kW",
            "context": "Sample.Periodic"
          },
          {
            "value": "31",
            "measurand": "SoC",
            "unit": "Percent",
            "context": "Sample.Periodic"
          }
        ]
      }
    ]
  }
]

This is the message with the most variation between vendors and the one to be most defensive about. Everything is optional except value. When measurand is absent it means Energy.Active.Import.Register. When unit is absent it means Wh for energy and W for power. The value is a string, even when it is a number. Some chargers send five sampled values per message, some send one, and one we have deployed sends the same register twice with different contexts.

  • Read the unit every time. The same model of charger, on two firmware versions, sent us Wh on one and kWh on the other. A billing engine that assumed one was wrong by a thousand.
  • The register only goes up. Session energy is the latest register minus meterStart, never a sum of samples. Summing samples of a cumulative register is the single most common billing bug we have seen in other people's systems.
  • SoC is the car's state of charge and it is a gift when present. It is what lets a dashboard show the driver a percentage and what lets you predict when a session will taper. Most AC chargers cannot read it; most DC chargers can.
  • Set the interval with MeterValueSampleInterval, not by hoping. It is a configuration key you set via ChangeConfiguration; the default on many chargers is 0, which means never.

7. StopTransaction: the reading that ends the bill

json[
  2,
  "1007",
  "StopTransaction",
  {
    "transactionId": 88213,
    "idTag": "04A2B3C4D5E680",
    "meterStop": 1291340,
    "timestamp": "2026-09-16T08:37:19Z",
    "reason": "EVDisconnected"
  }
]

[
  3,
  "1007",
  {
    "idTagInfo": {
      "status": "Accepted"
    }
  }
]

meterStop minus meterStart is the energy delivered: 1291340 minus 1284512 is 6,828 Wh, so 6.83 kWh, and that is the number on the invoice. The reason field tells you why it ended and is worth storing: EVDisconnected is the driver unplugging, Remote is you stopping it, PowerLoss is the charger losing mains mid-session, and Reboot, Local, HardReset, SoftReset, DeAuthorized, EmergencyStop and UnlockCommand are the rest.

A StopTransaction can also carry transactionData, an array of MeterValues in the same shape as message 6, so a charger that was offline for the whole session can hand you every sample at the end. Chargers are required to queue transaction messages while offline and deliver them when the socket returns, and the good ones do. That is why the offline rule in our billing engine is wait, then bill from the last sample only if the StopTransaction never comes, and mark that bill estimated.

What we got wrong: we rejected a StopTransaction whose transactionId we did not recognise, with a CALLERROR. The charger retried it every few seconds forever, because the specification says it must keep trying. Answer every StopTransaction with a CALLRESULT even when you do not know the transaction, log it loudly, and reconcile by hand. An unknown StopTransaction is a bug in your records, not the charger's, and it is not the charger's job to give up.

8. RemoteStartTransaction: the one you send

Everything above is the charger calling you. This is the one message in the other direction that every CSMS needs on day one, because it is how an app or a QR flow starts a session on a charger the driver did not tap a card on. Same framing, roles reversed: you send the CALL, the charger sends the CALLRESULT.

json[
  2,
  "srv-5c1d",
  "RemoteStartTransaction",
  {
    "connectorId": 1,
    "idTag": "APP-9f31a2"
  }
]

[
  3,
  "srv-5c1d",
  {
    "status": "Accepted"
  }
]

Accepted does not mean the session has started. It means the charger will try. What follows, if the driver actually plugs in, is a StatusNotification to Preparing, then an Authorize for that idTag (which you must accept, since you chose it), then a StartTransaction. If the driver walks away, nothing follows and the charger times out after its ConnectionTimeOut configuration value, which is usually 60 to 120 seconds. Your app should show pending until the StartTransaction arrives, and give up when that timeout passes, not when the RemoteStartTransaction was Accepted.

What we got wrong: we showed the driver a green tick on Accepted. They walked to the car, the cable was not seated, the session never started, and the tick had already told them it was fine.

The shape of a handler that survives all eight

Not a framework, a skeleton. The point is the order of operations, which is the part every first implementation gets backwards: parse, dispatch, persist, then reply. Never reply before you persist.

typescriptsocket.on("message", async (raw) => {
  const frame = JSON.parse(raw.toString());
  const [kind, id] = frame;

  if (kind === 3 || kind === 4) return settlePending(id, frame);   // a reply to something we sent

  const [, , action, payload] = frame;
  const handler = handlers[action];
  if (!handler) {
    return socket.send(JSON.stringify([4, id, "NotImplemented", "", {}]));
  }

  try {
    const result = await handler(chargePointId, payload);   // persists before it returns
    socket.send(JSON.stringify([3, id, result]));
  } catch (err) {
    socket.send(JSON.stringify([4, id, "InternalError", String(err), {}]));
  }
});

Three things this skeleton gets right that our first version did not. Replies to our own calls are handled before anything else, so a slow handler cannot block a RemoteStartTransaction answer. Unknown actions get NotImplemented rather than a dropped connection, so a charger with a vendor extension keeps working. And the handler's promise resolves only after the database write, so the reply cannot outrun the record.

The checklist we run before a new charger model goes live

  1. Echo the ocpp1.6 subprotocol in the handshake, and log a rejected handshake at warning level, not debug.
  2. Accept a second BootNotification on an open socket without complaint.
  3. Mark a charger online on any message, not only Heartbeat.
  4. Read the unit on every MeterValues sample and normalise to Wh before storing.
  5. Compute energy as register minus meterStart, never as a sum.
  6. Persist before replying to StartTransaction, and reply to every StopTransaction even when the id is unknown.
  7. Answer Authorize from cache in under a second.
  8. Treat RemoteStartTransaction Accepted as pending, not started, until the StartTransaction arrives.
  9. Set MeterValueSampleInterval explicitly, because the default is often never.
  10. Spell OccurenceConstraintViolation the way the specification does, wrong.

Every one of those is a bug we shipped at least once. The protocol is not complicated. The chargers are, and the eight messages above are where the difference shows.

Building a CSMS, or running chargers on one that keeps getting these wrong? We built and operate PlugEV on 40-plus chargers and we are glad to look at your message logs before you commit to a rewrite.

Talk to us about your charging platform

Frequently asked questions

What is the difference between OCPP 1.6 and OCPP 1.6-J?
1.6 is the protocol. 1.6-J is the JSON-over-WebSocket transport for it, as opposed to 1.6-S, which is SOAP over HTTP. Almost every charger sold in India since 2020 speaks 1.6-J, and it is what every example in this post uses.
Is the meter value in OCPP 1.6 in Wh or kWh?
Wh by default, and cumulative. But the unit is declared per sampled value and some chargers send kWh, so read it every time. meterStart and meterStop in StartTransaction and StopTransaction are always Wh.
Why does my charger keep resending StopTransaction?
Because you answered it with a CALLERROR or did not answer at all. The specification requires the charger to keep retrying transaction messages until it gets a CALLRESULT. Reply with a CALLRESULT even for a transaction you do not recognise, and reconcile afterwards.
Does RemoteStartTransaction Accepted mean the session started?
No. It means the charger will try. The session has started only when a StartTransaction arrives, which needs the driver to plug in within the charger's ConnectionTimeOut. Show pending until then.
RR
Written by
Ravi Rai

Founder of buildbyravirai, a web development agency based in Noida, India. 5+ years shipping Next.js, WordPress, Shopify, and Laravel projects for clients in India, USA, Canada, and the UK.

EV charging software

We build the software behind EV charging networks

PlugEV is ours: OCPP charge point management, RFID and UPI billing, and a driver app, live on 40 plus chargers. If you are standing up a network, we have already solved most of what is about to go wrong.