Skip to content
Mobility, Building an OCPP CSMS: What Worked, What BrokeMobility
Mobility

Building an OCPP CSMS: What Worked, What Broke

RRRavi Rai··11 min read

We finished the first production cut of an OCPP-compliant Central System (CSMS) for an EV charging operator a few weeks back, live at plugev.in as CHARGE TECH. It speaks OCPP 1.6-J today, with a 2.0.1 upgrade path mapped out, and currently runs about 40 chargers across two cities. This post is the version of the implementation notes we wished existed when we started, not a re-explanation of the spec, but the parts that actually trip you up in production.

Build or buy? Decide this before you read the rest

We built one, so read this as a warning from people with every incentive to tell you the opposite. Build only if charging is core to your business and you have at least two engineers who can own the WebSocket stack for the next 24 months. Otherwise, license it.

  • Buy when chargers are a feature of some other business: a property, a fleet, a retail site. You need them working, not owned. A licensed CSMS has you live in weeks and somebody else carries the protocol maintenance forever.
  • Build when the charging network is the business, you need control over pricing logic, roaming or settlement, and you can fund two engineers on it continuously. The protocol is not the hard part. The firmware quirks, transaction edge cases and clock problems in the rest of this guide are.
  • Hybrid when you want your own driver-facing app and billing on top of a licensed core. This is the most common answer in practice, and usually the one operators reach last and most expensively.

For what the licensed options actually cost in India, with real rupee figures and where each one falls down, see our comparison of EV charging software companies. What follows here is the build path: what worked, what broke, and the week we lost to clock drift.

What OCPP actually is (the 60-second version)

OCPP, Open Charge Point Protocol, is the open standard, maintained by the Open Charge Alliance, that defines how an EV charger talks to the operator's backend. The charger is the Charge Point (CP). Your backend is the Central System Management Service (CSMS). Every meaningful interaction, authorizing a user, starting a session, reporting energy delivered, pushing firmware, is an OCPP message.

  • OCPP 1.6-J, JSON over WebSocket, the workhorse of 2026. ~95% of chargers shipping in India today speak this. Stable, well-understood, mountains of vendor implementations.
  • OCPP 2.0.1, the modern revision. Better security (mutual TLS, key management), ISO 15118 plug-and-charge support, smart charging improvements, device data model. Adoption is growing but still under 20% of installed Indian fleet.
  • OCPP 1.6 SOAP, legacy XML variant. Avoid. If a vendor only supports SOAP, walk away.

1.6-J or 2.0.1? Pick one and commit

We picked 1.6-J. Here's the honest reasoning. The customer's first 200 chargers were already procured and all spoke 1.6-J. ISO 15118 plug-and-charge isn't a real requirement in India yet, there's no certificate authority infrastructure for it. And 2.0.1's device data model is genuinely powerful but quadruples the complexity of your CSMS for benefits the customer can't yet monetize.

What we did do: design the internal message bus to be version-agnostic. Every incoming OCPP frame gets translated into our own internal Charging Event format the moment it hits the WebSocket handler. The handler is the only thing that knows OCPP versions exist. When 2.0.1 becomes a customer ask, we add a second handler, the rest of the system doesn't change. This is the single biggest architectural decision we'd recommend to anyone starting today.

The transport layer is where most teams cut corners

OCPP-J runs over WebSocket with a sub-protocol header (ocpp1.6 or ocpp2.0.1). Sounds simple. Then production happens.

  • Use ws (Node.js) or Ratchet (PHP) only after you've benchmarked. We started on plain ws, hit ~1500 concurrent connections per Node process, then moved to a Go service using gorilla/websocket for the long-lived layer. Node now handles only short-lived REST and admin traffic.
  • WebSocket sub-protocol negotiation matters, chargers will silently disconnect if your Sec-WebSocket-Protocol response header is missing or wrong. Echo back the exact protocol the charger requested.
  • TLS terminates at your load balancer. Use wss:// always in production. Plain ws:// is only acceptable on a private network behind a VPN, which is rare for charging deployments.
  • Set TCP keepalive on the listener (60s) AND application-level OCPP heartbeats (300s). Either alone misses real-world failure modes. NAT gateways at malls and parking lots love to drop idle TCP connections at exactly 4 minutes 30 seconds.
  • Connection IDs in the URL, chargers connect to wss://your-host/ocpp/{chargerId}. The chargerId in the URL must match the chargerId in the BootNotification payload, otherwise reject the connection. We've seen vendors ship chargers with the same default ID.

The core message flow you'll implement first

OCPP 1.6 has 28 messages, but in practice you can ship a working CSMS with ten of them. Build these first, in this order:

  1. BootNotification, charger comes online, you respond with current time and heartbeat interval. Your first integration milestone.
  2. Heartbeat, keep-alive every N seconds. Trivial to implement. Critical to log so you can detect dead chargers.
  3. StatusNotification, connector state changes (Available, Preparing, Charging, Finishing, Faulted). This drives your operations dashboard.
  4. Authorize, driver presents an idTag (RFID UID or app-generated token), CSMS replies with Accepted, Blocked, Expired, Invalid, or ConcurrentTx.
  5. StartTransaction, charging session begins. CSMS issues a transactionId.
  6. MeterValues, periodic energy readings during the session. Sample every 30-60 seconds in production; sampling more frequently chews bandwidth without operational benefit.
  7. StopTransaction, session ends. Final meter reading, reason code, idTag for stop authorization.
  8. RemoteStartTransaction / RemoteStopTransaction, server-initiated session control, used by your mobile app.
  9. ChangeConfiguration / GetConfiguration, read and write charger config keys remotely.
  10. Reset, soft or hard reboot the charger. Last-resort recovery for hung firmware.

Everything else (DataTransfer, FirmwareUpdate, GetDiagnostics, ReserveNow, smart charging profiles) you build in phase two, after the basics are bulletproof.

Authorization the right way

Authorize is where most CSMS implementations have the worst bugs. The spec is permissive; reality demands strictness. Our rules:

  • Treat idTag as opaque case-insensitive bytes. Never assume it's hex, never assume it's a fixed length. Different RFID card vendors print UIDs in different formats and chargers normalize them inconsistently.
  • Cache authorization decisions on the charger via the LocalAuthList feature when a session-critical site has unstable connectivity. We push a snapshot of active idTags to the charger nightly. If the WebSocket is down at session start, the charger can still authorize against its local list.
  • Always check ConcurrentTx, the same idTag must not be active in two transactions across your entire fleet. We've seen drivers tap a card at one charger, walk to another while the first connector locks, and start a second session because the CSMS didn't enforce this.
  • Tag expiry is a server-side concept. Never trust the charger to enforce expiry; respond with Expired in the Authorize.conf payload.
  • Log every Authorize call with chargerId, idTag, decision, and reason. This is your single most valuable audit trail for support tickets.

Transaction lifecycle, the corner case factory

A charging session in OCPP looks linear in the spec. In production it's a state machine with at least seven valid end states, half of which involve the charger and CSMS disagreeing about what happened.

  • StartTransaction can arrive after the network blacks out and reconnects, hours after the session physically started. The timestamp in the payload is the truth, not the time you received it. Bill against the timestamp, not server time.
  • Stale transaction IDs, the charger may queue StartTransaction messages while offline. When it reconnects and sends them in a burst, deduplicate by chargerId + connectorId + meterStart + timestamp.
  • MeterValues arriving after StopTransaction is normal. Accept and store them; they're useful for dispute resolution.
  • StopTransaction.reason values matter. EVDisconnected and PowerLoss are not the same as Local or Remote, they're failure events that should trigger a refund evaluation, not just close the session.
  • Always reconcile the meterStop value against the sum of MeterValues. A discrepancy of more than ~0.1 kWh usually means the charger restarted mid-session and lost samples. Flag it for ops, don't silently bill the customer.

Time, heartbeats, and the bug that took us a week

Roughly 30% of chargers in our fleet had wrong system time when they first connected, sometimes by minutes, sometimes by years. The BootNotification.conf reply contains a currentTime field; the charger is supposed to sync to it. About 60% of the firmware revisions we've tested actually do.

The bug that took us a week: one vendor's chargers ignored currentTime entirely, ran on their internal RTC, and stamped MeterValues with timestamps from 2018. Our billing engine, sensibly, refused to bill negative-duration sessions. Sessions silently failed to invoice for two weeks before anyone noticed.

The fix: at session boundaries, if the timestamp in the OCPP payload is more than 10 minutes off our server clock, we override with server time and tag the transaction with a clock-skew flag. Ops gets an alert; firmware updates get prioritized for that vendor. Trust nothing the charger says about time without sanity-checking it.

Smart charging, worth implementing once you have 20+ chargers

Smart charging in OCPP 1.6 is the SetChargingProfile / ClearChargingProfile / GetCompositeSchedule message family. It lets you cap a charger's output dynamically, useful for load balancing on a constrained MSEB connection, time-of-use tariffs, or solar-aware charging.

Honest take: don't build this until you have a customer site that needs it. The spec is genuinely complex (stack levels, schedule purposes, recurring profiles, units of W vs A), and getting it subtly wrong on a real site can trip the main breaker. Our first smart charging customer was a 12-charger DC site with a 200 kVA grid limit, that was when the engineering effort paid off. Before that, ChangeConfiguration of MaxCurrent was enough to throttle individual chargers.

Remote operations and firmware updates

  • RemoteStartTransaction is the message your mobile app fires when a user taps 'Start' for a QR-scanned charger. Pre-validate balance, then send. Always handle the case where the charger replies Accepted but never sends StartTransaction within 30 seconds, the EV may be unplugged or the connector locked.
  • Reset (Soft or Hard) is the panic button. Reserve Hard for cases where Soft has already failed. Some firmware revisions hang during Hard reset and require an on-site power cycle. Don't trigger Hard from automation, make it ops-only.
  • FirmwareUpdate uses GetDiagnostics-style URLs. Host the firmware on S3 with signed URLs, expire after 24 hours. Track UpdateFirmwareStatusNotification messages until you see Installed or InstallationFailed. Failures need to alert ops; we've had bricked chargers that needed a vendor field visit.
  • GetDiagnostics dumps the charger's logs to your URL. Build the upload endpoint early, it's how you debug 80% of weird in-the-wild issues. Make it accept multipart uploads up to 50 MB.

Vendor quirks we actually hit

OCPP is a standard. Implementations vary. Some real ones from our fleet, redacted:

  • One vendor sends StatusNotification with connectorId 0 and connectorId 1 simultaneously for the same physical state change. Deduplicate or your dashboard flickers.
  • Another sends MeterValues with the energy register reset to zero on every boot, not as a continuous lifetime counter. Use the per-transaction delta, never the absolute value, when computing kWh delivered.
  • A third vendor's chargers send Heartbeat exactly every 60 seconds regardless of what HeartbeatInterval you configure. Stop trying to tune it; just accept the load.
  • One firmware revision sends idTag with a leading null byte for some MIFARE cards. Strip non-printable characters before lookup.
  • One DC charger sends StartTransaction.meterStart in Wh, MeterValues in kWh, and StopTransaction.meterStop in Wh again. Read the unit attribute on every sample. Never assume.

Maintain a per-vendor quirks file in your repo. Every fix that's specific to a vendor's firmware revision goes in there with a comment, the vendor, the firmware version, and a one-line repro. This document will save the next engineer who joins your team weeks of debugging.

The stack we shipped on

  • WebSocket gateway: Go (gorilla/websocket), single binary, ~50 MB RAM per 5,000 connections, deployed behind an AWS ALB with sticky sessions on chargerId
  • Message bus: Redis Streams between the gateway and the application layer. Lets us redeploy the API without dropping charger connections.
  • Application backend: Laravel 11 (PHP) for admin, billing, customer app APIs. The non-realtime parts of the system.
  • Database: PostgreSQL 16. Time-series data (MeterValues) lives in a partitioned table by month with a retention job; transactional data lives in regular tables.
  • Mobile: React Native app with a simple QR-scan-and-start flow plus session history
  • Monitoring: Grafana + Prometheus on the gateway, Sentry on the application layer, plus a 'charger health' dashboard that flags any charger we haven't seen a heartbeat from in 10 minutes
  • Hosting: AWS ap-south-1 (Mumbai). Latency to chargers in India matters more than people think, every OCPP request/response round-trip is in the critical path of the user experience.

What we'd do differently next time

  • Build the OCPP message log viewer in week one. We waited until month three. The hours spent grepping JSON logs in those three months would have paid for the viewer ten times over.
  • Write a CP simulator before writing the CSMS. Open-source ones exist (steve, ocpp-js); we wrote our own in Go and could load-test 10,000 simulated chargers on a laptop. This caught 80% of our bugs before any real charger ever connected.
  • Version-tag every internal event from day one. When you eventually add OCPP 2.0.1, you'll be glad you did.
  • Separate the billing engine from the OCPP engine completely. We almost didn't, and untangling them later would have been a nightmare.
  • Treat the spec as a starting point, not a contract. Real-world chargers will violate it. Code defensively.

Building or evaluating an OCPP CSMS for your charging network and want a second opinion on architecture, vendor selection, or scaling?

Book a Free Architecture Review

Frequently asked questions

Should I build or buy an OCPP CSMS?
Build only if the charging network is your core business and you can fund at least two engineers to own it for the next two years. If chargers support some other business, license a CSMS instead: you will be live in weeks rather than months, and the vendor absorbs the protocol maintenance. Most operators end up hybrid, running their own driver app and billing on a licensed core.
How long does it take to build an OCPP CSMS?
Our first production cut of an OCPP 1.6-J Central System took months rather than weeks, and the protocol itself was the smallest part of it. The time goes on transaction lifecycle edge cases, charger firmware quirks that vary by vendor, clock and heartbeat handling, and the operational tooling you need before you can run it in production.
Is OCPP 1.6-J still the right version to build on in 2026?
For most Indian operators, yes. Roughly 95 percent of chargers shipping in India today speak 1.6-J, and a CSMS that cannot talk to the hardware your customers already own is not useful. Design the internal message bus to be version-agnostic so 2.0.1 is a translation layer later rather than a rewrite.
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.