LATAM EV
OCPI 2.3.0 Edition 2

API Wiki

Complete technical reference for the Hub's 10 OCPI modules — endpoints, roles, request/response shapes, and error codes, extracted directly from the implementation.

Authentication

Every module endpoint (except the two async callback endpoints and the initial POST /credentials) requires a Bearer-style token in the Authorization header, using the literal scheme "Token", not "Bearer".

  • Header format: Authorization: Token <TOKEN_B> — no other scheme is accepted.
  • Tokens are hashed with SHA-256 before being stored or looked up; they are never persisted in plaintext.
  • Missing header → 2000 CLIENT_ERROR, HTTP 401.
  • Unknown or inactive connection → 2003 UNKNOWN_TOKEN, HTTP 401.
  • Valid connection but wrong role or party ownership for the requested resource → 2003 UNKNOWN_TOKEN, HTTP 403.
  • Callback endpoints (/commands/callback/{id}, /chargingprofiles/callback/{id}) require no Authorization header at all — their security boundary is the unguessable id in the URL path, not a bearer token.

Response Envelope

Every response (success or error) is wrapped in the same OCPI envelope shape.

Success: { "data": <T>, "status_code": 1000, "status_message": "Success", "timestamp": "<ISO 8601>" }
Error: { "data": {}, "status_code": <code>, "status_message": "<message>", "timestamp": "<ISO 8601>" } — the HTTP status is set independently by the route handler (401/403/404/400/409/422/502, as documented per endpoint below).

Pagination

Applies to every collection GET endpoint: Locations, Tariffs, Tokens, Sessions, CDRs, Invoice Reconciliations, and Hub Client Info.

  • Query params: offset (default 0), limit (default 50, clamped to the 1–100 range).
  • Response headers: X-Total-Count (total matching records), X-Limit (effective limit), and Link: <url>; rel="next" (only present when there are more pages).
  • Sessions, CDRs, Tariffs, Tokens, and Invoice Reconciliations list endpoints return records across ALL connections and parties — gated only by holding a valid, CONNECTED TOKEN_B, not by ownership.
  • Locations is the one exception: its list endpoint additionally filters to publish: true. The single-resource GET for a Location does not apply that filter.

Outbound Call Safety (SSRF Protection)

Every outbound call the Hub makes to a connected party's own system (Credentials handshake, Tokens authorize proxy, Commands dispatch, Charging Profiles dispatch) goes through the same hardened fetch wrapper.

  • Non-https:// URLs are rejected, unless the server was started with OCPI_ALLOW_LOOPBACK=true (a development/test-only escape hatch, never read from persisted config).
  • The target hostname is resolved via DNS and checked against private/loopback/link-local ranges (RFC1918 IPv4 ranges, 127.0.0.0/8, 169.254.0.0/16, and IPv6 loopback/ULA/link-local) before the request is made.
  • Redirects are followed manually and only to the same host as the original request; a cross-host redirect is rejected.
  • Every outbound call has a 10-second timeout.

1. Credentials & Registration

The one-time handshake a CSMS performs to join the Hub, plus the version-discovery endpoints every OCPI implementation exposes.

GET/api/ocpi/2.3.0/versions

Role: None

Lists the OCPI versions the Hub supports.

Response

{ "data": [{ "version": "2.3.0", "url": "https://<hub>/api/ocpi/2.3.0/details" }] }
GET/api/ocpi/2.3.0/details

Role: None

Details for the 2.3.0 version — the module endpoints the Hub exposes.

Response

{
  "data": {
    "version": "2.3.0",
    "endpoints": [{ "identifier": "credentials", "role": "HUB", "url": "https://<hub>/api/ocpi/2.3.0/credentials" }]
  }
}
POST/api/ocpi/2.3.0/credentials

Role: TOKEN_A (one-time, pre-shared, issued out of band)

Initial handshake. The Hub fetches your declared versions/details endpoints, then issues a fresh TOKEN_B and creates a CONNECTED HubConnection. Your TOKEN_A is revoked in the same transaction.

Request body

{
  "token": "<your own TOKEN_C for the Hub to use later>",
  "url": "https://<your-csms>/ocpi/versions",
  "roles": [{ "role": "CPO" | "EMSP" | "HUB", "party_id": "<3 chars>", "country_code": "<2 chars>" }]
}

Response

{
  "token": "<new TOKEN_B>",
  "url": "https://<hub>/api/ocpi/2.3.0/versions",
  "roles": [{ "role": "HUB", "party_id": "LEA", "country_code": "ZZ" }]
}

Errors

2001 · 400Invalid request body.
2003 · 401Unknown TOKEN_A.
2001 · 409TOKEN_A already used or revoked.
3001 · 502Your declared versions/details endpoint is unreachable or malformed.
3002 · 422Your versions document does not advertise 2.3.0.
PUT/api/ocpi/2.3.0/credentials

Role: TOKEN_B (current)

Rotates the current TOKEN_B for a brand-new one. No request body.

Response

{ "token": "<new TOKEN_B>", "url": "https://<hub>/api/ocpi/2.3.0/versions", "roles": [{ "role": "HUB", "party_id": "LEA", "country_code": "ZZ" }] }

Errors

2003 · 401Unknown or inactive TOKEN_B.
DELETE/api/ocpi/2.3.0/credentials

Role: TOKEN_B

Terminates the connection (status becomes TERMINATED).

Response

{}

Errors

2003 · 401Unknown TOKEN_B.

2. Locations

GET/api/ocpi/2.3.0/locations

Role: Any connected role

Paginated list of published Locations (publish: true only), ordered by most recently updated.

GET/api/ocpi/2.3.0/locations/{country_code}/{party_id}/{location_id}

Role: Any connected role

A single Location — no publish filter applies here, unlike the list endpoint.

Errors

2001 · 404Location not found.
PUT/api/ocpi/2.3.0/locations/{country_code}/{party_id}/{location_id}

Role: CPO, must own country_code/party_id

Full upsert of a Location and its nested EVSEs/Connectors. Returns HTTP 201 whether created or updated.

Request body

{
  "id": "loc-001",
  "publish": true,
  "address": "...", "city": "...", "country": "...",
  "coordinates": { "latitude": "-34.6", "longitude": "-58.4" },
  "evses": [{
    "uid": "EVSE1",
    "status": "AVAILABLE",
    "connectors": [{
      "id": "1", "standard": "...", "format": "SOCKET",
      "power_type": "AC_3_PHASE", "max_voltage": 400, "max_amperage": 32
    }]
  }]
}

Response

{
  "country_code": "AR", "party_id": "EVR", "id": "loc-001", "publish": true,
  "address": "...", "city": "...", "country": "...",
  "coordinates": { "latitude": "-34.6", "longitude": "-58.4" },
  "evses": [{ "uid": "EVSE1", "evse_id": "...", "status": "AVAILABLE",
    "connectors": [{ "id": "1", "standard": "...", "format": "SOCKET",
      "power_type": "AC_3_PHASE", "max_voltage": 400, "max_amperage": 32, "last_updated": "<ISO>" }],
    "last_updated": "<ISO>" }],
  "last_updated": "<ISO>"
}

Errors

2003 · 403Caller is not a CPO for this country_code/party_id.
2001 · 400Invalid request body.
  • evses[].connectors requires at least 1 entry when evses is present.
PATCH/api/ocpi/2.3.0/locations/{country_code}/{party_id}/{location_id}

Role: CPO, must own country_code/party_id

Partial update. The Location must already exist. publish/address/city/country/coordinates are merged onto the existing record and the full object is re-validated and re-upserted (including EVSEs/Connectors). Returns HTTP 200.

Errors

2001 · 404Location does not exist yet — PATCH never creates.

3. Tariffs

GET/api/ocpi/2.3.0/tariffs

Role: Any connected role

Paginated list of all Tariffs (no publish concept for this module).

GET/api/ocpi/2.3.0/tariffs/{country_code}/{party_id}/{tariff_id}

Role: Any connected role

A single Tariff.

Errors

2001 · 404Tariff not found.
PUT/api/ocpi/2.3.0/tariffs/{country_code}/{party_id}/{tariff_id}

Role: CPO, must own country_code/party_id

Full upsert. Returns HTTP 201.

Request body

{
  "id": "tariff-1", "currency": "USD", "type": "REGULAR",
  "elements": [{ "price_components": [{ "type": "ENERGY", "price": 0.35, "step_size": 1 }] }]
}

Response

{
  "country_code": "AR", "party_id": "EVR", "id": "tariff-1", "currency": "USD", "type": "REGULAR",
  "elements": [{ "price_components": [{ "type": "ENERGY", "price": 0.35, "step_size": 1 }] }],
  "last_updated": "<ISO>"
}

Errors

2003 · 403Caller is not a CPO for this party.
DELETE/api/ocpi/2.3.0/tariffs/{country_code}/{party_id}/{tariff_id}

Role: CPO, must own country_code/party_id

Deletes the Tariff.

Errors

2001 · 404Tariff not found.

4. Tokens & Authorisation

GET/api/ocpi/2.3.0/tokens

Role: Any connected role

Paginated list of all Tokens.

GET/api/ocpi/2.3.0/tokens/{country_code}/{party_id}/{token_uid}

Role: Any connected role

A single Token.

Errors

2001 · 404Token not found.
PUT/api/ocpi/2.3.0/tokens/{country_code}/{party_id}/{token_uid}

Role: EMSP, must own country_code/party_id

Full upsert. Returns HTTP 201.

Request body

{
  "uid": "04ABCD...", "type": "RFID", "contract_id": "...", "issuer": "...",
  "valid": true, "whitelist": "ALLOWED"
}

Response

{
  "country_code": "AR", "party_id": "EVR", "uid": "04ABCD...", "type": "RFID",
  "contract_id": "...", "issuer": "...", "valid": true, "whitelist": "ALLOWED",
  "last_updated": "<ISO>"
}

Errors

2003 · 403Caller is not an EMSP for this party.
PATCH/api/ocpi/2.3.0/tokens/{country_code}/{party_id}/{token_uid}

Role: EMSP, must own country_code/party_id

Partial update, applied field-by-field directly (no re-merge/re-validate pass, unlike Locations).

Errors

2001 · 404Token does not exist.
DELETE/api/ocpi/2.3.0/tokens/{country_code}/{party_id}/{token_uid}

Role: EMSP, must own country_code/party_id

Deletes the Token.

Errors

2001 · 404Token not found.
POST/api/ocpi/2.3.0/tokens/{country_code}/{party_id}/{token_uid}/authorize

Role: Caller must hold a CPO role (any country/party)

A CPO checking whether a token (owned by the eMSP identified by the path) may charge. The path segments identify the eMSP that owns the token, not the caller. This endpoint never returns an HTTP error for business failures — any internal failure (token not found, eMSP disconnected, endpoint not declared, 6-second proxy timeout) resolves to { "allowed": "BLOCKED" } rather than an exception.

Request body

{ "location_references"?: <any object, passed through as-is to the eMSP> }

Response

{ "allowed": "ALLOWED" }

Errors

2003 · 403Caller holds no CPO role at all.
2003 · 401Missing/invalid TOKEN_B.
  • The Hub proxies this call to the eMSP's own declared tokens endpoint with a 6-second timeout, and forwards whatever the eMSP responds as the allowed value.

5. Sessions

GET/api/ocpi/2.3.0/sessions

Role: Any connected role

Paginated list of all Sessions.

GET/api/ocpi/2.3.0/sessions/{country_code}/{party_id}/{session_id}

Role: Any connected role

A single Session.

Errors

2001 · 404Session not found.
PUT/api/ocpi/2.3.0/sessions/{country_code}/{party_id}/{session_id}

Role: CPO, must own country_code/party_id

Full upsert. Returns HTTP 201.

Request body

{
  "id": "sess-1", "start_date_time": "<ISO>", "kwh": 12.5,
  "cdr_token": { "country_code": "AR", "party_id": "EVR", "uid": "...", "type": "RFID", "contract_id": "..." },
  "auth_method": "WHITELIST", "location_id": "loc-001", "evse_uid": "EVSE1",
  "connector_id": "1", "currency": "USD", "status": "ACTIVE"
}

Errors

2003 · 403Caller is not a CPO for this party.
PATCH/api/ocpi/2.3.0/sessions/{country_code}/{party_id}/{session_id}

Role: CPO, must own country_code/party_id

Partial update, applied field-by-field directly.

Errors

2001 · 404Session does not exist.

6. CDRs

Charge Detail Records are create-only and immutable — there is no PUT/PATCH/DELETE for this module.

GET/api/ocpi/2.3.0/cdrs

Role: Any connected role

Paginated list of all CDRs.

GET/api/ocpi/2.3.0/cdrs/{country_code}/{party_id}/{cdr_id}

Role: Any connected role

A single CDR.

Errors

2001 · 404CDR not found.
POST/api/ocpi/2.3.0/cdrs/{country_code}/{party_id}/{cdr_id}

Role: CPO, must own country_code/party_id

Creates the CDR. Note this is POST, not PUT — a second POST with the same cdr_id is rejected. Returns HTTP 201.

Request body

{
  "id": "cdr-1", "start_date_time": "<ISO>", "end_date_time": "<ISO>",
  "cdr_token": { "country_code": "AR", "party_id": "EVR", "uid": "...", "type": "RFID", "contract_id": "..." },
  "auth_method": "WHITELIST",
  "cdr_location": { "id": "loc-001", "address": "...", "city": "...", "country": "...",
    "coordinates": { "latitude": "-34.6", "longitude": "-58.4" },
    "evse_uid": "EVSE1", "connector_id": "1", "connector_standard": "...",
    "connector_format": "SOCKET", "connector_power_type": "AC_3_PHASE" },
  "currency": "USD",
  "charging_periods": [{ "start_date_time": "<ISO>", "dimensions": [{ "type": "ENERGY", "volume": 12.5 }] }],
  "total_cost": { "excl_vat": 4.5 }, "total_energy": 12.5, "total_time": 0.5
}

Errors

2003 · 403Caller is not a CPO for this party.
2001 · 409cdr_id already exists — CDRs are immutable.

7. Commands

Not symmetric CRUD: one typed action per command_type, dispatched to the CPO owning the referenced Location or Session, with an immediate ACK followed by an asynchronous final result delivered via callback.

POST/api/ocpi/2.3.0/commands/{command_type}

Role: Caller must hold an EMSP role (any country/party)

command_type is one of CANCEL_RESERVATION, RESERVE_NOW, START_SESSION, STOP_SESSION, UNLOCK_CONNECTOR. START_SESSION/RESERVE_NOW resolve their target CPO via location_id; the other three via session_id. The response_url field in your request body is accepted by the schema but overridden — the Hub always injects its own internal callback URL when forwarding to the CPO.

Request body

// START_SESSION
{
  "response_url": "https://your-system/callback",
  "token": { "uid": "...", "type": "RFID", "contract_id": "..." },
  "location_id": "loc-001", "country_code": "AR", "party_id": "CPO1"
}

// STOP_SESSION / UNLOCK_CONNECTOR / CANCEL_RESERVATION
{
  "response_url": "https://your-system/callback",
  "session_id": "sess-1", "country_code": "AR", "party_id": "CPO1"
}

Response

{ "result": "ACCEPTED", "timeout": 30 }

Errors

2001 · 400Unknown command_type or invalid body.
2003 · 403Caller holds no EMSP role.
2001 · 404Referenced Location/Session not found.
2001 · 422Target CPO is not CONNECTED.
3001 · 422Target CPO has not declared a commands endpoint.
3001 · 502Target CPO unreachable, or returned an invalid ACK.
  • The Hub's outbound dispatch to the CPO has an 8-second timeout.
  • A pending Command not resolved within 30 seconds is marked TIMEOUT the next time it is read via GET.
POST/api/ocpi/2.3.0/commands/callback/{command_id}

Role: None (security boundary is the unguessable command_id)

Called by the target CPO with the final async result. On success, the Hub best-effort re-forwards the same payload to the originating EMSP's own commands endpoint (fire-and-forget, failures are swallowed).

Request body

{ "result": "ACCEPTED" | "REJECTED" | "FAILED" | "TIMEOUT" | "UNKNOWN_RESERVATION", "message"?: {} }

Errors

2001 · 404Unknown command_id.
2001 · 409Command already resolved, or already expired (30s).
GET/api/ocpi/2.3.0/commands/callback/{command_id}

Role: Any connected role

Fetches the current status of a Command. If the command has been pending for more than 30 seconds with no callback, it is lazily marked TIMEOUT before being returned.

Response

{
  "id": "<id>", "type": "START_SESSION", "payload": {},
  "ack_result": "ACCEPTED", "final_result": "ACCEPTED",
  "final_result_received_at": "<ISO>", "last_updated": "<ISO>"
}

8. Charging Profiles

Same async ACK + callback shape as Commands, but scoped to an existing Session rather than a Location, with the HTTP verb toward the CPO varying by action.

POST/api/ocpi/2.3.0/chargingprofiles/{country_code}/{party_id}/{session_id}/{action}

Role: Caller must hold an EMSP role

action is one of GET_ACTIVE_CHARGING_PROFILE, PUT_CHARGING_PROFILE, DELETE_CHARGING_PROFILE. The Hub calls the CPO with the matching HTTP verb (GET/PUT/DELETE respectively) at its declared chargingprofiles endpoint.

Request body

// PUT_CHARGING_PROFILE
{
  "response_url": "https://your-system/callback",
  "charging_profile": {
    "charging_rate_unit": "W",
    "charging_profile_period": [{ "start_period": 0, "limit": 7400 }]
  }
}

// GET_ACTIVE_CHARGING_PROFILE / DELETE_CHARGING_PROFILE
{ "response_url": "https://your-system/callback" }

Response

{ "result": "ACCEPTED", "timeout": 30 }

Errors

2001 · 400Unknown action or invalid body.
2003 · 403Caller holds no EMSP role.
2001 · 404Referenced Session not found.
2001 · 422Owning CPO is not CONNECTED.
3001 · 422Owning CPO has not declared a chargingprofiles endpoint.
3001 · 502CPO unreachable, or returned an invalid ACK.
  • Same 8-second dispatch timeout and 30-second resolution timeout as Commands.
  • Unlike Commands (which marks a stale request TIMEOUT), a stale Charging Profile request is marked FAILED on the next GET.
POST/api/ocpi/2.3.0/chargingprofiles/callback/{charging_profile_id}

Role: None (security boundary is the unguessable id)

Called by the CPO with the final result. Best-effort re-forwarded to the originating EMSP's own chargingprofiles endpoint.

Request body

{ "result": "ACCEPTED" | "REJECTED" | "FAILED" | "UNKNOWN_SESSION", "charging_profile"?: {...} }

Errors

2001 · 404Unknown id.
2001 · 409Already resolved or expired.
GET/api/ocpi/2.3.0/chargingprofiles/callback/{charging_profile_id}

Role: Any connected role

Fetches the current status of a charging profile request.

Response

{
  "id": "<id>", "session_id": "sess-1", "action": "PUT_CHARGING_PROFILE",
  "ack_result": "ACCEPTED", "final_result": "ACCEPTED",
  "charging_profile": {...}, "last_updated": "<ISO>"
}

9. Invoice Reconciliation

Financial reconciliation between CDRs and issued invoices across parties, with optional automatic multi-currency conversion to USD.

  • Supported currencies for automatic FX conversion: ARS, CLP, MXN, BRL, UYU.
  • Primary rate source: Frankfurter, one endpoint per central bank (BCRA/BCCH/BANXICO/BCB/BCU) — the actual published central-bank rate, not a generic market average.
  • Fallback source (only if the primary is unreachable): an unofficial Yahoo Finance market-price endpoint. This is a forex market price, not the official central-bank quote — used only as a last resort.
  • Rates are cached in memory for 1 hour, persisted to an audit table on every fetch, and also written to a diagnostic JSON snapshot on disk per currency.
GET/api/ocpi/2.3.0/invoicereconciliations

Role: Any connected role

Paginated list.

GET/api/ocpi/2.3.0/invoicereconciliations/{country_code}/{party_id}/{reconciliation_id}

Role: Any connected role

A single reconciliation record.

Errors

2001 · 404Not found.
PUT/api/ocpi/2.3.0/invoicereconciliations/{country_code}/{party_id}/{reconciliation_id}

Role: CPO or EMSP (either role for the party is accepted)

Full upsert, PUT-only (no POST, unlike CDRs). If discrepancy_amount is present, the Hub looks up the referenced CDR's currency and converts the amount to USD automatically.

Request body

{
  "id": "rec-1", "cdr_id": "cdr-1", "status": "DISPUTED",
  "discrepancy_description": "...",
  "discrepancy_amount": { "excl_vat": 1000 }
}

Response

{
  "country_code": "AR", "party_id": "EVR", "id": "rec-1", "cdr_id": "cdr-1", "status": "DISPUTED",
  "discrepancy_amount": { "excl_vat": 1000 },
  "discrepancy_currency": "ARS",
  "discrepancy_amount_usd": { "excl_vat": 1.05 },
  "exchange_rate_used": 950.3,
  "last_updated": "<ISO>"
}

Errors

2001 · 400Referenced cdr_id not found (only checked when discrepancy_amount is set).
2001 · 400Currency not supported for conversion (see FX section below).
3000 · 502Exchange rate source unavailable.
  • discrepancy_currency, discrepancy_amount_usd and exchange_rate_used only appear when discrepancy_amount was included in the request — a status-only PUT preserves whatever FX fields were already there.
DELETE/api/ocpi/2.3.0/invoicereconciliations/{country_code}/{party_id}/{reconciliation_id}

Role: CPO or EMSP

Deletes the record.

Errors

2001 · 404Not found.

10. Hub Client Info

Read-only module — no PUT/PATCH/DELETE. Lets any connected party see who else is connected to the Hub.

  • Status values map from the Hub's internal connection status: PLANNED → PLANNED, CONNECTED → CONNECTED, SUSPENDED → SUSPENDED, TERMINATED → STOPPED.
GET/api/ocpi/2.3.0/hubclientinfo

Role: Any connected role

Paginated list, one entry per (connection × role) pair — a connection with 2 declared roles produces 2 entries.

GET/api/ocpi/2.3.0/hubclientinfo/{country_code}/{party_id}

Role: Any connected role

All role entries matching that country/party. Returns an empty array (not a 404) if none exist.

Response

[{ "party_id": "EVR", "country_code": "AR", "role": "CPO", "status": "CONNECTED", "last_updated": "<ISO>" }]

Full status code reference

Every response carries one of these status_code values inside the envelope, alongside an independent HTTP status set per endpoint (documented above).

1000SUCCESSEvery successful response.
2000CLIENT_ERRORGeneric client error — used across every route when the Authorization header is missing.
2001INVALID_PARAMETERSInvalid JSON body, and most "not found" / validation-failed cases across all modules.
2002NOT_ENOUGH_INFOReserved — not currently raised by any handler.
2003UNKNOWN_TOKENInvalid or missing TOKEN_B/TOKEN_A, and also role/party-ownership mismatches (only the HTTP status differs: 401 vs 403 — the Hub has no separate "forbidden" OCPI code).
3000SERVER_ERRORExchange-rate source failure in Invoice Reconciliation.
3001UNABLE_TO_USE_APIFailed handshake network calls; Commands/Charging Profiles ACK failures (unreachable CPO, invalid ACK, endpoint not declared).
3002UNSUPPORTED_VERSIONThe CSMS's versions document does not include 2.3.0 during the Credentials handshake.

Cross-module patterns

  • Write ownership: Locations/Tariffs/Sessions/CDRs require the caller to be the CPO that owns the target country_code/party_id. Tokens requires the caller to be the EMSP that owns it. Invoice Reconciliation accepts either role for that party. Commands/Charging Profiles only require the caller to hold an EMSP role somewhere — not scoped to a specific party — since these actions target a different CPO's system.
  • PUT returns HTTP 201 uniformly for create-or-update across every upsert module — there is no distinct status for "created" vs "updated".
  • Both async modules (Commands, Charging Profiles) use the same 8-second outbound dispatch timeout and a separate 30-second overall resolution timeout. A stale, unresolved Command is marked TIMEOUT; a stale Charging Profile request is marked FAILED — this asymmetry is intentional in the current implementation.