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.
/api/ocpi/2.3.0/versionsRole: None
Lists the OCPI versions the Hub supports.
Response
{ "data": [{ "version": "2.3.0", "url": "https://<hub>/api/ocpi/2.3.0/details" }] }/api/ocpi/2.3.0/detailsRole: 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" }]
}
}/api/ocpi/2.3.0/credentialsRole: 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 · 400 | Invalid request body. |
| 2003 · 401 | Unknown TOKEN_A. |
| 2001 · 409 | TOKEN_A already used or revoked. |
| 3001 · 502 | Your declared versions/details endpoint is unreachable or malformed. |
| 3002 · 422 | Your versions document does not advertise 2.3.0. |
/api/ocpi/2.3.0/credentialsRole: 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 · 401 | Unknown or inactive TOKEN_B. |
/api/ocpi/2.3.0/credentialsRole: TOKEN_B
Terminates the connection (status becomes TERMINATED).
Response
{}Errors
| 2003 · 401 | Unknown TOKEN_B. |
2. Locations
/api/ocpi/2.3.0/locationsRole: Any connected role
Paginated list of published Locations (publish: true only), ordered by most recently updated.
/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 · 404 | Location not found. |
/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 · 403 | Caller is not a CPO for this country_code/party_id. |
| 2001 · 400 | Invalid request body. |
- evses[].connectors requires at least 1 entry when evses is present.
/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 · 404 | Location does not exist yet — PATCH never creates. |
3. Tariffs
/api/ocpi/2.3.0/tariffsRole: Any connected role
Paginated list of all Tariffs (no publish concept for this module).
/api/ocpi/2.3.0/tariffs/{country_code}/{party_id}/{tariff_id}Role: Any connected role
A single Tariff.
Errors
| 2001 · 404 | Tariff not found. |
/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 · 403 | Caller is not a CPO for this party. |
/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 · 404 | Tariff not found. |
4. Tokens & Authorisation
/api/ocpi/2.3.0/tokensRole: Any connected role
Paginated list of all Tokens.
/api/ocpi/2.3.0/tokens/{country_code}/{party_id}/{token_uid}Role: Any connected role
A single Token.
Errors
| 2001 · 404 | Token not found. |
/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 · 403 | Caller is not an EMSP for this party. |
/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 · 404 | Token does not exist. |
/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 · 404 | Token not found. |
/api/ocpi/2.3.0/tokens/{country_code}/{party_id}/{token_uid}/authorizeRole: 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 · 403 | Caller holds no CPO role at all. |
| 2003 · 401 | Missing/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
/api/ocpi/2.3.0/sessionsRole: Any connected role
Paginated list of all Sessions.
/api/ocpi/2.3.0/sessions/{country_code}/{party_id}/{session_id}Role: Any connected role
A single Session.
Errors
| 2001 · 404 | Session not found. |
/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 · 403 | Caller is not a CPO for this party. |
/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 · 404 | Session does not exist. |
6. CDRs
Charge Detail Records are create-only and immutable — there is no PUT/PATCH/DELETE for this module.
/api/ocpi/2.3.0/cdrsRole: Any connected role
Paginated list of all CDRs.
/api/ocpi/2.3.0/cdrs/{country_code}/{party_id}/{cdr_id}Role: Any connected role
A single CDR.
Errors
| 2001 · 404 | CDR not found. |
/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 · 403 | Caller is not a CPO for this party. |
| 2001 · 409 | cdr_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.
/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 · 400 | Unknown command_type or invalid body. |
| 2003 · 403 | Caller holds no EMSP role. |
| 2001 · 404 | Referenced Location/Session not found. |
| 2001 · 422 | Target CPO is not CONNECTED. |
| 3001 · 422 | Target CPO has not declared a commands endpoint. |
| 3001 · 502 | Target 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.
/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 · 404 | Unknown command_id. |
| 2001 · 409 | Command already resolved, or already expired (30s). |
/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.
/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 · 400 | Unknown action or invalid body. |
| 2003 · 403 | Caller holds no EMSP role. |
| 2001 · 404 | Referenced Session not found. |
| 2001 · 422 | Owning CPO is not CONNECTED. |
| 3001 · 422 | Owning CPO has not declared a chargingprofiles endpoint. |
| 3001 · 502 | CPO 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.
/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 · 404 | Unknown id. |
| 2001 · 409 | Already resolved or expired. |
/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.
/api/ocpi/2.3.0/invoicereconciliationsRole: Any connected role
Paginated list.
/api/ocpi/2.3.0/invoicereconciliations/{country_code}/{party_id}/{reconciliation_id}Role: Any connected role
A single reconciliation record.
Errors
| 2001 · 404 | Not found. |
/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 · 400 | Referenced cdr_id not found (only checked when discrepancy_amount is set). |
| 2001 · 400 | Currency not supported for conversion (see FX section below). |
| 3000 · 502 | Exchange 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.
/api/ocpi/2.3.0/invoicereconciliations/{country_code}/{party_id}/{reconciliation_id}Role: CPO or EMSP
Deletes the record.
Errors
| 2001 · 404 | Not 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.
/api/ocpi/2.3.0/hubclientinfoRole: Any connected role
Paginated list, one entry per (connection × role) pair — a connection with 2 declared roles produces 2 entries.
/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).
| 1000 | SUCCESS | Every successful response. |
| 2000 | CLIENT_ERROR | Generic client error — used across every route when the Authorization header is missing. |
| 2001 | INVALID_PARAMETERS | Invalid JSON body, and most "not found" / validation-failed cases across all modules. |
| 2002 | NOT_ENOUGH_INFO | Reserved — not currently raised by any handler. |
| 2003 | UNKNOWN_TOKEN | Invalid 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). |
| 3000 | SERVER_ERROR | Exchange-rate source failure in Invoice Reconciliation. |
| 3001 | UNABLE_TO_USE_API | Failed handshake network calls; Commands/Charging Profiles ACK failures (unreachable CPO, invalid ACK, endpoint not declared). |
| 3002 | UNSUPPORTED_VERSION | The 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 markedFAILED— this asymmetry is intentional in the current implementation.