OCPI Commands Module: Remote Charging Control Across Networks

How eMSPs trigger start/stop/reserve actions on CPO stations via OCPI. The Commands module is what makes "Start Session" buttons in apps work.

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:

  1. eMSP sends command. The CPO immediately responds with an acknowledgment (ACCEPTED, REJECTED, NOT_SUPPORTED). That is not the outcome, only “we received it.”
  2. CPO does the work, talking to the station over OCPP and waiting for the EV.
  3. 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_url paths 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.

Quick check

Q1. A driver taps "Start Charging" in their eMSP app. Which OCPI module is invoked?

Frequently asked questions

What can the OCPI Commands module do?

It supports remote start of a charging session, remote stop, reservations, and reservation cancellations. eMSPs initiate these; CPOs execute and report results.

How does a remote start work?

The eMSP sends a START_SESSION command with a token and target EVSE. The CPO sends an immediate ACK, dispatches the command to the station, and asynchronously reports the result back via a callback URL.

Found this useful? Share it.