I spent the better part of three months building a Central System (CSMS) for EV chargers. This post is about what that actually looks like in Node.js — the messy parts, the boring parts, and the parts where everything finally clicked.
If you're here because you Googled "OCPP Node.js" and found a pile of unmaintained repos and outdated forum posts — yeah, same. Let me save you some time.
What Even Is OCPP
If you're coming from a pure web background, OCPP is probably unfamiliar territory. It's a WebSocket-based RPC protocol that chargers (Charge Points) use to talk to a backend server (Central System or CSMS). The charger connects to your server, sends a BootNotification to announce itself, and from that point on it's a two-way RPC channel — the charger can send you Heartbeat, MeterValues, StatusNotification, etc., and your server can push back commands like RemoteStartTransaction, ChangeConfiguration, Reset.
There are two major versions in the wild: OCPP 1.6 (most hardware in deployment today) and OCPP 2.0.1 / 2.1 (newer, more structured, more verbose). They're not backward compatible. Hardware vendors ship one or the other, or sometimes both.
The RPC framing is simple enough — a CALL is [2, uniqueId, action, payload], a CALLRESULT is [3, uniqueId, payload], and CALLERROR is [4, uniqueId, errorCode, description, details]. Simple in principle. But writing all the schema validation, connection lifecycle management, reconnect logic, multi-version type safety, and authentication on top of ws by hand is where things start to unravel.
The DIY Attempt
My first cut was hand-rolled on top of the ws package. It looked something like this:
import WebSocket, { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 3000 });
wss.on("connection", (socket, req) => {
const identity = req.url?.split("/").pop();
socket.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
const [type, id, action, payload] = msg;
if (type === 2) {
if (action === "BootNotification") {
socket.send(JSON.stringify([3, id, {
status: "Accepted",
currentTime: new Date().toISOString(),
interval: 300,
}]));
}
}
});
});
Fine for a demo. Not fine for production. Problems start piling up fast:
-
No types on
payload— you're just trusting JSON.parse and hoping the charger sent what you expect -
No request/response correlation — when a charger responds to your
RemoteStart, how do you match it back to the caller? You're writing your own promise-per-uniqueId tracking - No reconnect handling — chargers drop, networks flicker, you need backoff logic on the client side
-
No auth — OCPP security profiles exist (Basic Auth, TLS, Mutual TLS), and none of that is in
wsby default -
Multi-version chaos — 1.6 and 2.0.1 have different field names for the same concepts.
idTagvsidToken, for instance. You end up withif (protocol === "ocpp1.6")forks everywhere
After about three weeks I had a codebase that worked for the happy path and fell over on everything else. So I went looking for a library.
Finding ocpp-ws-io
I created ocpp-ws-io while digging into ocpp and server implementation with node js. It's a TypeScript-first OCPP client and server framework for Node.js, supporting OCPP 1.6, 2.0.1, and 2.1. It handles the RPC framing, schema validation, auth, and clustering — all the infrastructure work I was tired of doing myself.
Here's what the same boot notification handler looks like with it:
import { OCPPServer } from "ocpp-ws-io";
const server = new OCPPServer({
protocols: ["ocpp1.6", "ocpp2.0.1"],
});
server.on("client", (client) => {
client.handle("ocpp1.6", "BootNotification", ({ params }) => {
// params is fully typed — chargePointVendor, chargePointModel, etc.
console.log(`Boot from ${params.chargePointVendor} / ${params.chargePointModel}`);
return {
status: "Accepted",
currentTime: new Date().toISOString(),
interval: 300,
};
});
});
await server.listen(3000);
The return type is also inferred — if you try to return status: "Whatever", TypeScript catches it at compile time. That alone saved me from a class of bugs I was discovering in logs at 2am.
Plugging It Into an Existing Express Server
This was the first thing I actually needed to figure out. Our backend already had an Express app handling REST endpoints. I didn't want to run OCPP on a separate port with a separate process — I wanted it on the same server, under a specific path like /api/v1/chargers/:id.
ocpp-ws-io is framework-agnostic. You can attach it to any existing http.Server:
import express from "express";
import { createServer } from "http";
import { OCPPServer } from "ocpp-ws-io";
const app = express();
const httpServer = createServer(app);
// Your normal REST routes
app.get("/health", (req, res) => res.json({ ok: true }));
app.use("/api/v1", yourApiRouter);
// OCPP server attached to the same HTTP server
const ocppServer = new OCPPServer({
protocols: ["ocpp1.6", "ocpp2.0.1"],
server: httpServer, // ← the key part
});
// Dynamic routing with path param extraction
const chargerRoute = ocppServer.route("/api/v1/chargers/:id");
chargerRoute.on("client", (client) => {
const chargerId = client.handshake.params.id; // extracted from the URL
client.handle("ocpp1.6", "BootNotification", ({ params }) => {
return {
status: "Accepted",
currentTime: new Date().toISOString(),
interval: 300,
};
});
client.handle("ocpp1.6", "Heartbeat", () => ({
currentTime: new Date().toISOString(),
}));
client.on("disconnect", () => {
console.log(`${chargerId} disconnected`);
});
});
httpServer.listen(3000);
One HTTP server, REST and WebSocket on the same port, path-based routing. Works exactly as expected.
Authentication
OCPP 1.6 uses Basic Auth in the WebSocket handshake URL (ws://user:pass@host/path). The library handles the WebSocket upgrade and gives you a hook before the connection is accepted:
ocppServer.auth((ctx) => {
const { identity, headers } = ctx.handshake;
const authHeader = headers["authorization"];
if (!authHeader) {
return ctx.reject(401, "Missing credentials");
}
const [, encoded] = authHeader.split(" ");
const [user, pass] = Buffer.from(encoded, "base64").toString().split(":");
// Check against your database
const isValid = validateChargerCredentials(user, pass);
if (!isValid) {
return ctx.reject(401, "Invalid credentials");
}
ctx.accept({ session: { chargerId: user } });
});
You can attach arbitrary data to the session in ctx.accept() and read it back later in client.session. Useful for things like storing the charger's database ID or tenant info.
Connection Middleware
One pattern that genuinely surprised me was that the middleware system works at two levels: the HTTP Upgrade (before the WebSocket is even opened) and the OCPP message layer. This lets you handle rate limiting, IP filtering, and logging cleanly without polluting your handlers:
import { defineMiddleware } from "ocpp-ws-io";
// Connection-phase middleware — runs before WebSocket handshake completes
ocppServer.use(
defineMiddleware(async (ctx, next) => {
const ip = ctx.handshake.remoteAddress;
if (await isIPBanned(ip)) {
return ctx.reject(403, "Forbidden");
}
await next();
})
);
// Message-phase middleware — wraps every RPC call per client
chargerRoute.on("client", (client) => {
client.use(async (ctx, next) => {
const t = Date.now();
await next();
console.log(`[${client.identity}] ${ctx.action} — ${Date.now() - t}ms`);
});
// handlers below...
});
The logging middleware is something I have running in staging to catch slow handlers. The timing data showed that one of our MeterValues handlers was taking 200ms because it was doing a synchronous database write. Fixed it, now it's async.
Sending Commands to a Charger
OCPP is bidirectional — you need to be able to push commands to chargers, not just respond to them. The client.call() method handles this with a proper promise that resolves when the charger responds:
// Somewhere in a REST handler or background job
async function remoteStartSession(chargerId: string, rfidTag: string) {
const client = ocppServer.getClient(chargerId); // get connected client by identity
if (!client) throw new Error("Charger not connected");
const result = await client.call("ocpp1.6", "RemoteStartTransaction", {
connectorId: 1,
idTag: rfidTag,
});
// result.status is typed: "Accepted" | "Rejected"
if (result.status !== "Accepted") {
throw new Error(`Charger rejected start: ${result.status}`);
}
}
This is where the typed RPC really earns its keep. On my hand-rolled version I had a bug where I was sending idToken instead of idTag for OCPP 1.6. TypeScript would have caught that at compile time. Instead I found it in production when a charger started rejecting every remote start.
Handling Multiple OCPP Versions
We have a mix of hardware — some chargers only support 1.6, a few newer units support 2.0.1. The subprotocol negotiation happens automatically during the WebSocket handshake. In your handlers you just register per-version:
client.handle("ocpp1.6", "StatusNotification", ({ params }) => {
// params: { connectorId, status, errorCode, ... }
updateConnectorStatus(client.identity, params.connectorId, params.status);
return {};
});
client.handle("ocpp2.0.1", "StatusNotificationRequest", ({ params }) => {
// params: { connectorId, connectorStatus, evseId, ... } — different shape
updateConnectorStatus(client.identity, params.connectorId, params.connectorStatus);
return {};
});
You can also check client.protocol at runtime if you need version-branching logic, but mostly I've been able to keep them in separate handlers.
What I'm Not Using (Yet)
The library has a Redis adapter for multi-instance deployments via pub/sub. We're currently on a single Node process, so I haven't wired that up. When we need to scale horizontally behind a load balancer it'll matter — charger A's WebSocket might land on server instance 1, but a REST request to trigger RemoteStart might hit instance 2. Without clustering, that call fails silently because the client isn't on that process.
I also haven't used the Protocol Proxy package yet. That one translates OCPP 1.6 frames to 2.0.1 format dynamically, which would be useful if we ever want to standardize our CSMS on 2.0.1 internally while still accepting 1.6 hardware. It's a separate package (ocpp-protocol-proxy or similar) under the same ecosystem.
The CLI
There's a companion CLI (ocpp-ws-cli, available via npx) that I've been using for local testing. The one I use most is ocpp simulate — it spins up an interactive charge point emulator in the terminal with a live TUI showing voltage, current, and SoC. You can trigger plug-ins, send RFID taps, and fire MeterValues without any physical hardware. Useful when you want to test a specific flow without digging out a dev charger.
npx ocpp-ws-cli simulate --endpoint ws://localhost:3000/api/v1/chargers/TEST-01 --protocol ocpp1.6
There's also ocpp bench for throughput testing and ocpp fuzz for throwing malformed payloads at your server to make sure your validation is solid. I ran ocpp fuzz against our server after turning on strict mode and it caught two edge cases where our handlers were crashing on unexpected payload shapes.
Honest Thoughts
Things I like:
- The TypeScript types are genuinely useful, not just cosmetic. Auto-generated from the OCPP JSON schemas directly.
- The Express integration is seamless. No port juggling.
- The middleware API is clean and familiar if you've written Express middleware.
- The CLI tooling saves a lot of time on test setup.
Things to be aware of:
- The documentation is still filling out for some of the newer packages (Protocol Proxy, Smart Charge Engine). Expect to read the source for edge cases.
- OCPP itself is under-specified in places. The library can give you type safety, but the protocol still has implementation-defined behavior that you'll need to handle per your hardware vendor.
- Strict mode is off by default. Turn it on early in development —
strictMode: truein the server config. You want schema validation errors to be loud during testing, not silent in production.
Quick Reference — The Bits I Look Up Most Often
Server with Express
const app = express();
const httpServer = createServer(app);
const ocppServer = new OCPPServer({ protocols: ["ocpp1.6"], server: httpServer });
httpServer.listen(3000);
Route + Auth
ocppServer.auth((ctx) => ctx.accept());
const route = ocppServer.route("/api/v1/chargers/:id");
route.on("client", (client) => { /* ... */ });
Inbound handler (charger → server)
client.handle("ocpp1.6", "BootNotification", ({ params }) => ({
status: "Accepted",
currentTime: new Date().toISOString(),
interval: 300,
}));
Outbound call (server → charger)
const result = await client.call("ocpp1.6", "RemoteStopTransaction", {
transactionId: 42,
});
Message middleware
client.use(async (ctx, next) => {
await next();
// runs after handler
});
If you're building anything in this space — CSMS, charge point simulator, fleet management backend — and you're starting from scratch on the OCPP layer, this library is worth your time. It's not magic, but it takes about two weeks of infrastructure work off your plate so you can focus on the actual business logic.
GitHub: rohittiwari-dev/ocpp-ws-io
Docs: ocpp-ws-io.rohittiwari.me
Install: npm install ocpp-ws-io
Top comments (0)