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 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:

  1. eMSP sends command → CPO immediately responds with an acknowledgment (ACCEPTED, REJECTED, NOT_SUPPORTED) — this isn’t the outcome, just “we received it.”
  2. CPO does the work (talks to the station via OCPP, waits for the EV, etc.)
  3. 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_url paths 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

  1. Commands are asynchronous. The synchronous response is just an ACK. Wait for the callback to know the outcome.

  2. START_SESSION is the workhorse. Every “Start Charging” button in an eMSP app ultimately translates to this command.

  3. 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.

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.