When a driver opens their eMSP’s app and taps “Start Charging,” something has to actually happen at the charging station. The Commands module is what bridges the gap — letting an eMSP trigger actions on a CPO’s stations even though they’re different companies’ systems.
This is the remote-control layer of OCPI.
What the Commands module supports
Four primary command types:
- START_SESSION — begin a charging session at a specific EVSE
- STOP_SESSION — end an in-progress session
- RESERVE_NOW — reserve an EVSE for future use
- CANCEL_RESERVATION — release 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 interesting because they involve real-world hardware. When an eMSP says “start a session at EVSE 1,” the CPO can’t immediately know if it worked — they need to send instructions to the station, wait for it 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 → CPO immediately responds with an acknowledgment (ACCEPTED, REJECTED, NOT_SUPPORTED) — this isn’t the outcome, just “we received it.”
- CPO does the work (talks to the station via OCPP, waits for the EV, etc.)
- CPO POSTs the result back to the eMSP at a callback URL the eMSP provided in step 1.
This pattern keeps the synchronous response fast (sub-second) while accommodating the slow real-world physics.
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: This is 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"
Common implementation gotchas
A few things that catch real-world implementations — the first one is the single most common Commands bug:
Treating the synchronous ACCEPTED as success — the #1 mistake
The synchronous response to a START_SESSION is a CommandResponse, and its
result: ACCEPTED means only 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 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 OCPI 2.2.1 spec’s
Commands module.
Two different result enums
The sync CommandResponse.result is a short list — ACCEPTED, REJECTED,
NOT_SUPPORTED, UNKNOWN_SESSION. The async CommandResult.result is the
richer outcome set — ACCEPTED, REJECTED, EVSE_OCCUPIED,
EVSE_INOPERATIVE, FAILED, NOT_SUPPORTED, TIMEOUT,
UNKNOWN_RESERVATION. Don’t 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 — otherwise a stray or replayed CommandResult can be mis-attributed
to the wrong session.
START_SESSION — the most common command
The flow when a driver taps “Start Charging” in their eMSP’s app:
Step 1: eMSP sends START_SESSION
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": "TNM",
"uid": "12345678905880",
"type": "APP_USER",
"contract_id": "NL-TNM-C12345678-X",
"issuer": "TheNewMotion",
"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’s just receipt acknowledgment.
Step 3: CPO dispatches to the station (via OCPP)
The CPO’s backend sends a RemoteStartTransaction message to the station over OCPP. The station offers the connector to the EV. The EV plugs in (or rather, the lock engages — the EV is presumably already plugged in for this scenario).
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 result is ACCEPTED, the Sessions module takes over — Session events flow as the charging progresses. If REJECTED or some 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 (e.g. the session started, or the reservation was placed)
- CANCELED_RESERVATION — the reservation was successfully cancelled (returned for CANCEL_RESERVATION)
- REJECTED — the CPO declined (unspecified reason)
- EVSE_OCCUPIED — the EVSE is already in use by another session
- EVSE_INOPERATIVE — the EVSE is broken or out of service
- NOT_SUPPORTED — the requested operation isn’t supported by this CPO
- TIMEOUT — the CPO timed out before getting a definitive answer from the station
- UNKNOWN_RESERVATION — for CANCEL_RESERVATION, the referenced reservation wasn’t 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 to drive driver-facing error messages and retry logic.
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 —
with the wire-level details that trip people up called out in the comments.
(Comments use // and # for teaching; real JSON has no comments.)
Phase 1 — eMSP sends the command (POST to the CPO)
# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# The eMSP is the Sender for Commands — 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" — then 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": "TNM", // 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-TNM-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": "TheNewMotion", // 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": "GreenPower Energy", // required within energy_contract: the supplier's name (max 64)
"contract_id": "GP-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) — 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 now — 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"
}
CPO acknowledges, then asynchronously stops the session and reports back.
This is what powers “Stop Charging” buttons in apps. The physical disconnect from the cable still requires the driver to be at the station, but the session can be cleanly ended remotely.
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": "TNM",
"uid": "12345678905880",
"type": "APP_USER",
"contract_id": "NL-TNM-C12345678-X",
"issuer": "TheNewMotion",
"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 (marks the EVSE as RESERVED in the Locations module) or rejects. If accepted, the EVSE is unavailable to other drivers until the reservation expires or is used.
Real-world: this enables UX like “Book your charger before you leave the office so it’s ready when you arrive.”
Not all CPOs support reservations — it depends on the station hardware and the operator’s policy. Some networks heavily restrict reservations to avoid empty stalls.
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 include enough context (token, target EVSE, etc.) that they can in theory be replayed. But OCPI doesn’t 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).
In practice, eMSPs:
- Don’t 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 timeout) before showing the driver any other action.
Implementation gotchas
Synchronous ACCEPTED ≠ session started
The most common mistake: a developer sees ACCEPTED in the synchronous response and shows the driver “Charging started!”. Wrong. Wait for the asynchronous callback. The driver should see “Sending command…” then “Charging started” only after the callback says ACCEPTED.
Timeouts and lost callbacks
If the CPO’s callback never arrives within the timeout, the eMSP doesn’t know what happened. Two possibilities: (a) the session is happening but the callback was lost (the Sessions module will eventually catch it), (b) the command failed silently. Implementations should handle both.
EVSE state contention
A driver in their app says “Start Charging at EVSE 1” just as another driver physically plugs in at the same station. Race condition. The CPO has to pick a winner — typically the physical plug-in. The eMSP gets EVSE_OCCUPIED back.
Authentication context
The token used in the command must match the customer of the eMSP making the request. CPOs validate this — a request that doesn’t match should be REJECTED.
Reservation abuse
Reservations are valuable but can be abused (sit on a charger you’ll never use). Most CPOs apply business rules: max reservation duration, max reservations per customer, idle fees if the driver doesn’t show up. OCPI doesn’t enforce these; the CPO does.
Three things to take from this
-
Commands are asynchronous. The synchronous response is just an ACK. Wait for the callback to know the outcome.
-
START_SESSION is the workhorse. Every “Start Charging” button in an eMSP app ultimately translates to this command.
-
Reservations are CPO-discretionary. Not all networks support RESERVE_NOW. eMSPs should handle the NOT_SUPPORTED case gracefully.
The Commands module is small but operationally critical. It’s where the abstract “remote roaming” becomes concrete actions on real charging hardware.