Building a Fully Functional OCPP CSMS: What Worked, What Broke, What We'd Do Differently
We finished the first production cut of an OCPP-compliant Central System (CSMS) for an EV charging operator a few weeks back. 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.
If you're a founder or CTO evaluating whether to buy a CSMS or build one, the short answer is: build it 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. With that caveat out of the way, here's how we built ours.
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:
- BootNotification — charger comes online, you respond with current time and heartbeat interval. Your first integration milestone.
- Heartbeat — keep-alive every N seconds. Trivial to implement. Critical to log so you can detect dead chargers.
- StatusNotification — connector state changes (Available, Preparing, Charging, Finishing, Faulted). This drives your operations dashboard.
- Authorize — driver presents an idTag (RFID UID or app-generated token), CSMS replies with Accepted, Blocked, Expired, Invalid, or ConcurrentTx.
- StartTransaction — charging session begins. CSMS issues a transactionId.
- MeterValues — periodic energy readings during the session. Sample every 30-60 seconds in production; sampling more frequently chews bandwidth without operational benefit.
- StopTransaction — session ends. Final meter reading, reason code, idTag for stop authorization.
- RemoteStartTransaction / RemoteStopTransaction — server-initiated session control, used by your mobile app.
- ChangeConfiguration / GetConfiguration — read and write charger config keys remotely.
- 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 ReviewFounder of buildbyRaviRai, a freelance 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.
Working with us in your city
Keep Reading
Building an EV Charging Station with RFID Authentication: What We Learned
A practical, opinionated guide for anyone building or operating EV charging stations in India — how the RFID + OCPP stack actually fits together, what hardware works, what breaks in production, and what it costs.
OCPP 2.0.1 Migration Playbook for Indian Operators: When to Move, What Breaks, What's Worth It
When does an Indian EV charging operator running OCPP 1.6-J actually need to move to 2.0.1? Honest cost/benefit, the device data model, ISO 15118 plug-and-charge in India, and the migration path that doesn't require freezing your fleet.