The three-step async flow
Three parties, three hops, and every one can fail on its own:
- eMSP to CPO — the OCPI command, answered immediately with a CommandResponse.
- CPO to charger — an OCPP
RemoteStartTransaction(1.6J) orRequestStartTransaction(2.0.1) over the charger's live WebSocket. - Back up the chain — the charger answers, the CPO POSTs a CommandResult to your
response_url, and a Session object appears separately.
The Session is what actually confirms charging. The CommandResult confirms the charger accepted the instruction; the Session confirms a charge exists. They are different facts and they arrive separately.
The fields
| Field | Required | Notes |
|---|---|---|
response_url | Yes | Where the CommandResult is POSTed |
token | Yes | The full Token object — not just a uid |
location_id | Yes | The site |
evse_uid | No | Omit and the CPO chooses an available EVSE |
connector_id | No | Only meaningful with evse_uid |
authorization_reference | No | Echoed onto the Session and CDR — always send it |
Sending the whole Token object rather than a reference is deliberate: the CPO may need to authorise without being able to reach you, so it needs the token's valid and whitelist state in hand.
On EVSE targeting: omitting evse_uid lets the CPO pick, which raises success rates. But drivers are standing at a specific charger, so in practice you almost always want to name it — a driver at bay A2 does not want the CPO starting bay A7.
Example: the full exchange
POST /ocpi/2.2.1/commands/START_SESSION
Authorization: Token <base64 token>
OCPI-from-country-code: IN
OCPI-from-party-id: EMS
OCPI-to-country-code: IN
OCPI-to-party-id: EFI
X-Request-ID: 7c1a4e90
X-Correlation-ID: booking-88231
{
"response_url": "https://emsp.example.com/ocpi/2.2.1/commands/START_SESSION/7c1a4e90",
"token": {
"country_code": "IN", "party_id": "EMS",
"uid": "DR-88231", "type": "APP_USER",
"contract_id": "IN-EMS-C0142",
"issuer": "Example Mobility Services",
"valid": true, "whitelist": "ALLOWED",
"last_updated": "2026-08-11T08:55:00Z"
},
"location_id": "LOC-ND-014",
"evse_uid": "EVSE-014-02",
"authorization_reference": "auth-4471"
}The immediate answer — queued, not started:
HTTP/1.1 200 OK
{
"data": { "result": "ACCEPTED", "timeout": 30 },
"status_code": 1000,
"timestamp": "2026-08-11T09:11:59Z"
}Then, once the charger has responded over OCPP:
POST /ocpi/2.2.1/commands/START_SESSION/7c1a4e90
X-Correlation-ID: booking-88231
{ "result": "ACCEPTED" }And separately, through the Sessions module, the object that actually proves a charge exists:
PUT /ocpi/2.2.1/sessions/IN/EMS/sess-77c1
{
"id": "sess-77c1",
"start_date_time": "2026-08-11T09:12:00Z",
"kwh": 0.000,
"auth_method": "COMMAND",
"authorization_reference": "auth-4471",
"location_id": "LOC-ND-014",
"evse_uid": "EVSE-014-02",
"connector_id": "1",
"status": "ACTIVE",
"last_updated": "2026-08-11T09:12:00Z"
}
Reading the failure modes
| Result | What went wrong | What to tell the driver |
|---|---|---|
REJECTED (sync) | CPO refused before trying — unknown location, bad token | Cannot start here — try another charger |
NOT_SUPPORTED | This CPO does not support remote start | Use your card or the CPO's own app |
EVSE_OCCUPIED | Another vehicle is already using it | This charger is in use |
EVSE_INOPERATIVE | Faulted or disabled | This charger is out of order |
TIMEOUT | Charger never answered — usually offline | Cannot reach this charger right now |
FAILED | Charger tried and could not | Check the cable is fully inserted, then retry |
FAILED is worth special handling. The most common physical cause is a cable that is not seated properly, and a message telling the driver to reseat it resolves a meaningful share of these without any support contact.
Where implementations go wrong
- Showing success on the CommandResponse.
ACCEPTEDat phase one means queued. Showing “charging started” there produces a support ticket every time the charger then reportsEVSE_OCCUPIED. Wait for the Session. - Retrying on timeout. The charger may be mid-start. A retry can produce two sessions and a double charge. Reconcile against Sessions instead.
- Non-idempotent callback handlers. Duplicate results are normal. Key on the request ID and ignore repeats.
- Omitting
authorization_reference. Without it, matching this command to its Session and CDR becomes timestamp guesswork. - Not checking
capabilitiesfirst. The Locations module tells you whether an EVSE isREMOTE_START_STOP_CAPABLE. Checking saves a guaranteed failure. - Ignoring stale EVSE status. Starting against an EVSE your map last saw an hour ago is how
EVSE_OCCUPIEDrates climb.
A flow that holds up in production
- Check the EVSE is
AVAILABLEandREMOTE_START_STOP_CAPABLE. - Send the command with
authorization_referenceset. - On
ACCEPTED, show starting — never started. - Wait for whichever arrives first: the CommandResult or the Session.
- Show charging only once a Session exists.
- On timeout, check Sessions before declaring failure.
- Never auto-retry; let the driver decide, after you have confirmed no session started.
The whole design principle: the Session is the truth, the command is only a request.
Frequently asked questions
The CPO answers immediately with a CommandResponse confirming the request was queued, relays it to the charger as an OCPP RemoteStartTransaction or RequestStartTransaction, then POSTs a CommandResult to your response_url. A Session object arrives separately and is what actually confirms charging began.
No. ACCEPTED in the CommandResponse means the request was queued for processing. The charger may still report EVSE_OCCUPIED or fail. Only the appearance of a Session object confirms a charge exists.
No. The charger may be mid-start, and a retry can create two sessions and a double charge. Check the Sessions module to see whether a session actually started before doing anything else.
Usually yes. Omitting it lets the CPO pick any available EVSE, which raises success rates, but a driver is standing at a specific charger and will not appreciate the CPO starting a different bay.
Because the CPO may need to authorise the driver without being able to reach the eMSP. Sending the full token gives it the valid and whitelist state it needs to make that decision locally.