When a driver opens their eMSP’s app and taps “Start Charging,” something has to actually happen at the charging station. The Commands module bridges that gap. It lets an eMSP trigger actions on a CPO’s stations even though the two run completely separate systems.
This is the remote-control layer of OCPI. It is also one of the few places where the eMSP asks the CPO to do something rather than to tell it something, and that asymmetry drives most of the design.
What the Commands module supports
Four primary command types:
- START_SESSION begins a charging session at a specific EVSE
- STOP_SESSION ends an in-progress session
- RESERVE_NOW reserves an EVSE for future use
- CANCEL_RESERVATION releases a reservation
Each command flows in the same pattern: eMSP sends, CPO acknowledges synchronously, then reports the final outcome asynchronously via callback.
The asynchronous pattern
Commands are unusual among OCPI modules because they touch real-world hardware. When an eMSP says “start a session at EVSE 1,” the CPO cannot know straight away whether it worked. It has to send instructions to the station, wait for the station to negotiate with the EV, and observe the result. That takes seconds to tens of seconds.
So OCPI uses a two-phase pattern:
- eMSP sends command. The CPO immediately responds with an acknowledgment (ACCEPTED, REJECTED, NOT_SUPPORTED). That is not the outcome, only “we received it.”
- CPO does the work, talking to the station over OCPP and waiting for the EV.
- CPO POSTs the result back to the eMSP at a callback URL the eMSP provided in step 1.
This keeps the synchronous response fast (sub-second) while accommodating the slow physics at the far end of the wire.
sequenceDiagram
participant App as Driver app (eMSP)
participant EM as eMSP backend
participant CPO as CPO backend
participant CP as Charge point
App->>EM: Tap "Start Charging"
EM->>CPO: POST /commands/START_SESSION<br/>(token, EVSE, response_url)
CPO-->>EM: 200 OK: ACCEPTED (sync ACK)
Note over EM,CPO: Not the outcome, just receipt
CPO->>CP: OCPP RemoteStartTransaction
CP-->>CPO: Station started charging
CPO->>EM: POST response_url: ACCEPTED (async result)
EM->>App: Show "Charging started"
Where the two-phase contract bites
Three places where implementations go wrong. The first is easy to ship and hard to notice, because it only misbehaves when the station fails.
Treating the synchronous ACCEPTED as success
The synchronous response to a START_SESSION is a CommandResponse, and its
result: ACCEPTED means exactly one thing: the CPO received the command and
will attempt it. It does not mean charging started. The real outcome arrives
later, asynchronously, as a CommandResult POSTed to your response_url. Or it
never arrives at all and you hit the timeout.
Wrong, showing success on the sync ACK:
# eMSP sends START_SESSION, gets the sync CommandResponse back:
{
"data": { "result": "ACCEPTED", "timeout": 30 }, // "received, will attempt"
"status_code": 1000, "status_message": "Success", "timestamp": "2026-07-03T09:00:01Z"
}
# BUG: app immediately renders "Charging started!" but nothing has started yet.
# The station could still come back EVSE_OCCUPIED, EVSE_INOPERATIVE, or FAILED.
Right, wait for the async CommandResult at your response_url:
# App shows "Sending command…" after the sync ACCEPTED, then waits.
# Later, the CPO POSTs the real result to response_url:
{ "result": "ACCEPTED" } # NOW it is safe to show "Charging started"
# … or { "result": "EVSE_OCCUPIED" } / "EVSE_INOPERATIVE" / "FAILED" → show an error.
# … or nothing arrives within `timeout` seconds → treat as lost, do NOT assume success.
The full two-phase round trip, both the sync CommandResponse and the async
CommandResult POST, is written out in the worked example below. For the
normative field-by-field definition, see the Commands chapter of the OCPI 2.2.1
specification (published June 2020) in the
OCPI repository.
Two different result enums
The sync CommandResponse.result is a short list: ACCEPTED, REJECTED,
NOT_SUPPORTED, UNKNOWN_SESSION. The async CommandResult.result carries the
richer outcome set: ACCEPTED, REJECTED, EVSE_OCCUPIED,
EVSE_INOPERATIVE, FAILED, NOT_SUPPORTED, TIMEOUT,
UNKNOWN_RESERVATION. Do not expect the operational codes (like
EVSE_OCCUPIED) on the synchronous call. They only ever come back on the async
callback.
The response_url must be unique per attempt
Generate a distinct response_url path for every command you send. That lets
you match an incoming callback to the exact command that produced it and detect
duplicates. Share one URL across commands and a stray or replayed CommandResult
can be mis-attributed to the wrong session.
START_SESSION step by step
The flow when a driver taps “Start Charging” in their eMSP’s app:
Step 1: eMSP sends START_SESSION
The token field carries a complete Token object from the
Tokens module, not a bare identifier.
POST /ocpi/cpo/2.2.1/commands/START_SESSION
Authorization: Token <CPO credentials>
{
"response_url": "https://emsp.example.com/ocpi/emsp/2.2.1/commands/response/123",
"token": { // the complete Token object (full field set below in the worked example)
"country_code": "NL",
"party_id": "EXM",
"uid": "12345678905880",
"type": "APP_USER",
"contract_id": "NL-EXM-C12345678-X",
"issuer": "Example Mobility",
"valid": true,
"whitelist": "ALLOWED",
"last_updated": "2026-07-03T08:55:00Z"
},
"location_id": "LOC1",
"evse_uid": "3256", // optional
"connector_id": "1", // optional
"authorization_reference": "SESS-REF-0007" // optional
}
Step 2: CPO synchronously acknowledges
{
"result": "ACCEPTED",
"timeout": 30 // seconds until the eMSP should consider this lost
}
ACCEPTED means “we received it and we’ll try to execute.” It does not mean “the session started successfully.” It is receipt acknowledgment and nothing more.
Step 3: CPO dispatches to the station (via OCPP)
The CPO’s backend sends a RemoteStartTransaction (OCPP 1.6) or a RequestStartTransaction (OCPP 2.0.1 onward) message to the station, and the station offers the connector to the EV. In this scenario the car is already plugged in, so what changes is the lock and the authorization state rather than the physical connection. The OCPP side of remote start and stop has its own failure modes worth knowing before you debug across the boundary.
Step 4: CPO POSTs the final result to the eMSP
When the CPO knows the outcome (within seconds usually, up to the timeout):
POST https://emsp.example.com/ocpi/emsp/2.2.1/commands/response/123
Authorization: Token <CPO credentials>
{
"result": "ACCEPTED" // one of the CommandResultType values: ACCEPTED, CANCELED_RESERVATION, EVSE_OCCUPIED, EVSE_INOPERATIVE, FAILED, NOT_SUPPORTED, REJECTED, TIMEOUT, UNKNOWN_RESERVATION
}
If the result is ACCEPTED, the Sessions module takes over and Session objects flow as charging progresses. If it is REJECTED or a failure code, the eMSP shows the driver an error.
Result codes
The async CommandResult.result is a CommandResultType. The full set of values:
- ACCEPTED: the command succeeded, meaning the session started or the reservation was placed
- CANCELED_RESERVATION: returned for CANCEL_RESERVATION when the hold was released
- REJECTED: the CPO declined, without stating a reason
- EVSE_OCCUPIED: another session already holds the EVSE
- EVSE_INOPERATIVE: the EVSE is broken or out of service
- NOT_SUPPORTED: this CPO does not implement the requested operation
- TIMEOUT: the CPO gave up waiting for a definitive answer from the station
- UNKNOWN_RESERVATION: the reservation referenced by CANCEL_RESERVATION was not found
- FAILED: general failure
Note that UNKNOWN_SESSION is a synchronous CommandResponseType value (returned when a STOP_SESSION references a session the CPO doesn’t know); it is not a CommandResultType and never appears on the async callback.
The eMSP uses these codes to drive driver-facing error messages and retry logic. They sit at a different layer from the OCPI envelope’s status_code, which only reports whether the request itself was well formed. OCPI error responses (coming soon) covers that layer.
A complete worked example: eMSP starts a session, CPO reports back
The snippets above are trimmed for clarity. Here is the full two-phase round
trip: the eMSP sending START_SESSION, the CPO’s synchronous CommandResponse, and
then the CPO’s asynchronous CommandResult POSTed back to the response_url. The
wire-level details that trip people up are called out in the comments.
(Comments use // and # for teaching; real JSON has no comments.)
Both directions authenticate with credentials tokens exchanged during the
Credentials handshake, and each side sends the token its counterparty issued
to it, which is why the Authorization header changes value between phases.
Phase 1: eMSP sends the command (POST to the CPO)
# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# The eMSP is the Sender for Commands, so it INITIATES. It POSTs the command to
# the CPO's Receiver Interface. The command name (START_SESSION) is the last
# path segment. The eMSP includes a response_url it owns; the CPO will call
# that URL back LATER with the real outcome.
POST /ocpi/cpo/2.2.1/commands/START_SESSION HTTP/1.1
Host: cpo.example.com
# Auth scheme is the literal word "Token", NOT "Bearer", followed by the
# credentials token the CPO issued to THIS eMSP during the Credentials handshake.
Authorization: Token <token-the-CPO-issued-to-this-eMSP>
Content-Type: application/json
# Recommended so both sides can trace one call across their logs; echo them back.
X-Request-ID: 12345
X-Correlation-ID: 67890
{
// The URL the CPO will POST the ASYNC result to. Make it unique per attempt
// (note the trailing id) so you can match a callback to the command that
// caused it and detect duplicates. The eMSP must be listening here.
"response_url": "https://emsp.example.com/ocpi/emsp/2.2.1/commands/response/123",
"token": { // the full Token object identifying the driver
"country_code": "NL", // required: ISO 3166-1 alpha-2 of the eMSP that owns the token (2 chars)
"party_id": "EXM", // required: the eMSP's party id (3 chars)
"uid": "12345678905880", // required: token id as read at the charge point (max 36)
"type": "APP_USER", // required: RFID | APP_USER | AD_HOC_USER | OTHER
"contract_id": "NL-EXM-C12345678-X", // required: the driver's contract id at the eMSP (max 36)
"visual_number": "DF000-2001-8999-1", // optional: number printed on the physical RFID card
"issuer": "Example Mobility", // required: issuing company name printed on the token (max 64)
"group_id": "DF000-2001-8999", // optional: groups tokens that act as one unit (e.g. a fleet)
"valid": true, // required: is this token currently valid?
"whitelist": "ALLOWED", // required: ALWAYS | ALLOWED | ALLOWED_OFFLINE | NEVER
"language": "en", // optional: ISO 639-1 preferred language for the driver
"default_profile_type": "GREEN", // optional: CHEAP | FAST | GREEN | REGULAR (smart-charging default)
"energy_contract": { // optional: lets the driver use their own energy supplier
"supplier_name": "Example Energy", // required within energy_contract: the supplier's name (max 64)
"contract_id": "EE-NL-99887766" // optional: the driver's contract id at that supplier (max 64)
},
"last_updated": "2026-07-03T08:55:00Z" // required: RFC 3339, UTC ("Z"), when the token last changed
},
"location_id": "LOC1", // WHERE to start: a Location the CPO published
"evse_uid": "3256", // optional: pin to a specific EVSE (its uid)
"connector_id": "1", // optional: pin to a specific connector
"authorization_reference": "SESS-REF-0007" // optional: ties to a prior RealTime auth
}
# ── CPO ──▶ eMSP (the SYNCHRONOUS response) ─────────────────────────────────
# This comes back on the SAME HTTP call, within a second or so. The "data" is a
# CommandResponse. CRITICAL: result: ACCEPTED here means ONLY "command received,
# will attempt", NOT that charging started. Showing the driver "Charging
# started" on this response is the classic bug (see gotchas above). Wait for the
# async CommandResult in Phase 2.
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 12345
X-Correlation-ID: 67890
{
"data": {
"result": "ACCEPTED", // ACCEPTED | REJECTED | NOT_SUPPORTED | UNKNOWN_SESSION
"timeout": 30, // seconds: if no async callback arrives by then,
// treat the command as lost (do NOT assume success)
"message": [ // optional human-readable text, multi-language
{ "language": "en", "text": "Command received, contacting the station." }
]
},
// THE key OCPI gotcha: HTTP 200 only means the HTTP call arrived. You must
// ALSO check status_code INSIDE the envelope. 1000 = success. You can get
// HTTP 200 with status_code 2001 (invalid parameters), so always read the
// envelope, never trust the HTTP status alone. 2xxx = client error, 3xxx = server.
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-03T09:00:01Z" // RFC 3339, UTC ("Z")
}
Phase 2: CPO POSTs the real outcome to the response_url
# ── CPO ──▶ eMSP ────────────────────────────────────────────────────────────
# Seconds later (up to the timeout), the CPO has talked to the station over OCPP
# and knows what actually happened. It POSTs a CommandResult to the EXACT
# response_url the eMSP sent in Phase 1. Direction is reversed, so is the token:
# the CPO now authenticates with the token the eMSP issued to IT.
POST /ocpi/emsp/2.2.1/commands/response/123 HTTP/1.1
Host: emsp.example.com
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json
X-Request-ID: 24680
X-Correlation-ID: 67890 # reuse the correlation id to tie the whole flow
{
// CommandResult. THIS is the authoritative outcome the driver waits on.
"result": "ACCEPTED", // ACCEPTED | REJECTED | EVSE_OCCUPIED |
// EVSE_INOPERATIVE | FAILED | NOT_SUPPORTED |
// TIMEOUT | UNKNOWN_RESERVATION
"message": [ // optional human-readable detail
{ "language": "en", "text": "Charging session started." }
]
}
# ── eMSP ──▶ CPO (the response to the callback) ─────────────────────────────
# The eMSP acknowledges it received the result. Again: an OCPI envelope, so the
# CPO must check status_code 1000 here too, not just the HTTP 200.
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 24680
X-Correlation-ID: 67890
{
"data": {}, // nothing to return; the ack is the point
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-03T09:00:14Z"
}
Only after the Phase 2 CommandResult says ACCEPTED does the eMSP show the
driver “Charging started” and hand off to the Sessions module. Had the
CommandResult been EVSE_OCCUPIED or EVSE_INOPERATIVE, the app shows an error
instead. And if no Phase 2 POST arrives before timeout seconds elapse, the
eMSP must treat the command as lost rather than assume it worked.
STOP_SESSION: remote disconnect
Similar flow:
POST /ocpi/cpo/2.2.1/commands/STOP_SESSION
{
"response_url": "https://emsp.example.com/ocpi/emsp/2.2.1/commands/response/124",
"session_id": "SESSION_12345"
}
The CPO acknowledges, then asynchronously stops the session and reports back.
This is what powers “Stop Charging” buttons in apps. Unplugging the cable still requires the driver to be standing at the station, but the session can be ended cleanly from anywhere.
RESERVE_NOW: booking a station
For pre-booking an EVSE:
POST /ocpi/cpo/2.2.1/commands/RESERVE_NOW
{
"response_url": "https://emsp.example.com/ocpi/emsp/2.2.1/commands/response/125",
"token": { // the complete Token object, same shape as the worked example above
"country_code": "NL",
"party_id": "EXM",
"uid": "12345678905880",
"type": "APP_USER",
"contract_id": "NL-EXM-C12345678-X",
"issuer": "Example Mobility",
"valid": true,
"whitelist": "ALLOWED",
"last_updated": "2026-07-03T08:55:00Z"
},
"expiry_date": "2026-10-22T15:30:00Z",
"reservation_id": "RES_12345",
"location_id": "LOC1",
"evse_uid": "3256", // optional
"authorization_reference": "SESS-REF-0008" // optional
}
The CPO either accepts, marking the EVSE as RESERVED in the Locations module, or rejects. While a reservation stands the EVSE is unavailable to other drivers, until it expires or the holder uses it.
That is what enables UX like “book your charger before you leave the office so it is ready when you arrive.”
RESERVE_NOW is optional in OCPI. A CPO that has not implemented it answers NOT_SUPPORTED, and whether a given operator offers reservations at all depends on the station hardware and that operator’s own policy. Treat NOT_SUPPORTED as a normal outcome to render rather than an error to log. On the station side the hold still has to be expressed in OCPP, which has its own reservation flow and its own expiry semantics.
CANCEL_RESERVATION: releasing a hold
POST /ocpi/cpo/2.2.1/commands/CANCEL_RESERVATION
{
"response_url": "https://emsp.example.com/ocpi/emsp/2.2.1/commands/response/126",
"reservation_id": "RES_12345"
}
Frees the EVSE before the reservation’s natural expiry.
Command idempotency
Commands carry enough context (token, target EVSE, and so on) that they can in theory be replayed. But OCPI does not require strict idempotency. If an eMSP sends the same START_SESSION twice, the CPO might start two sessions (bad) or might reject the duplicate (good), and nothing in the spec decides which.
The defensive pattern on the eMSP side:
- Do not auto-retry on timeout. Show the driver an error instead.
- Generate unique
response_urlpaths per attempt so duplicate callbacks are detectable. - Wait for the final callback, or for the timeout to elapse, before offering the driver any other action.
Implementation gotchas
Timeouts and lost callbacks
If the CPO’s callback never arrives within the timeout, the eMSP does not know what happened. Two possibilities: the session is running and the callback was lost, or the command failed silently. Both need handling, and the Sessions module is the tiebreaker. A Session object turning up for the same token and EVSE tells you it was the first case, and you can reconcile after the fact.
EVSE state contention
A driver in the app says “Start Charging at EVSE 1” just as another driver physically plugs in at the same station. The CPO has to pick a winner, and it resolves that locally rather than over OCPI: whichever authorization the station accepts first takes the connector. The eMSP that lost gets EVSE_OCCUPIED back on the async callback.
Authentication context
The token in the command must belong to a customer of the eMSP making the request. A CPO should validate that pairing and answer REJECTED when the token and the requesting party do not line up.
Reservation policy
Reservations are useful and easy to abuse, because holding a connector you never use costs the holder nothing. OCPI does not enforce any policy here, so the guard rails live in the CPO’s own business logic: a maximum reservation duration, a cap on concurrent holds per customer, an idle fee when the driver does not turn up. None of that is expressed over OCPI. The eMSP only ever sees REJECTED, or the reservation quietly expiring.
The part that is easy to miss
Most OCPI modules exchange state: one party publishes objects, the other reads them, and silence on the wire simply means nothing changed. Commands does not work like that. The eMSP is asking for an action on hardware it does not own, so silence stops being neutral. It becomes a third outcome you have to render, neither success nor failure, just unknown until the Sessions module resolves it.
Two consequences fall out of that inversion. The eMSP has to behave as a server for a URL it invented seconds earlier, the same trick the ChargingProfiles module uses, which means an eMSP cannot be a pure client no matter how read-only the rest of its integration looks. And the patience of your UI is set by your counterparty, because the timeout in the CommandResponse comes from the CPO. A driver watching a spinner is waiting on a number your code did not choose.