PHP2Shell alert: critical PHP flaws CVE-2026-109618 / 109619 — check your servers and get patched fast
HashTech Systems
EV Charging
Part 2 of 10

OCPI Versions and Credentials: The Token A/B/C Handshake

How OCPI 2.2.1 version discovery and the credentials handshake work, which token is used when, and the Authorization header encoding trap.

HashTech Engineering · October 26, 2026 · 6 min read

Before two OCPI parties can exchange a single location or session, they have to find each other's endpoints, agree on a version, and swap API tokens. This is done with two modules every implementation must support: versions and credentials. The procedure is short, but it is where most first-day integration problems happen, so it is worth getting exactly right.

This is part 2 of the series. If you have not read it, Part 1 introduces roles and modules.

The versions endpoint

Each party publishes a single, stable versions URL. A GET on it returns the OCPI versions the party supports, each with a URL to the version details:

http
GET /ocpi/versions HTTP/1.1
Host: cpo.example.com
Authorization: Token ZWJmM2IzOTktNzc5Zi00NDk3LTliOWQtYWM2YWQzY2M0NGQy
json
{
  "data": [
    { "version": "2.1.1", "url": "https://cpo.example.com/ocpi/2.1.1" },
    { "version": "2.2.1", "url": "https://cpo.example.com/ocpi/2.2.1" }
  ],
  "status_code": 1000,
  "timestamp": "2026-10-26T09:12:44Z"
}

Pick the highest version both sides support, then fetch its details. The version details list every module endpoint, with its identifier, interface role and URL:

json
{
  "data": {
    "version": "2.2.1",
    "endpoints": [
      { "identifier": "credentials", "role": "SENDER", "url": "https://cpo.example.com/ocpi/2.2.1/credentials" },
      { "identifier": "locations", "role": "SENDER", "url": "https://cpo.example.com/ocpi/cpo/2.2.1/locations" },
      { "identifier": "tariffs", "role": "SENDER", "url": "https://cpo.example.com/ocpi/cpo/2.2.1/tariffs" },
      { "identifier": "sessions", "role": "SENDER", "url": "https://cpo.example.com/ocpi/cpo/2.2.1/sessions" },
      { "identifier": "cdrs", "role": "SENDER", "url": "https://cpo.example.com/ocpi/cpo/2.2.1/cdrs" },
      { "identifier": "tokens", "role": "RECEIVER", "url": "https://cpo.example.com/ocpi/cpo/2.2.1/tokens" },
      { "identifier": "commands", "role": "RECEIVER", "url": "https://cpo.example.com/ocpi/cpo/2.2.1/commands" }
    ]
  },
  "status_code": 1000,
  "timestamp": "2026-10-26T09:12:45Z"
}

Store these URLs per partner and use them verbatim. Do not construct module URLs yourself by appending /locations to a base path: partners are free to put endpoints anywhere, and some put the Sender and Receiver interfaces of the same module on different hosts.

The three tokens

OCPI uses three tokens during registration. The naming is from the spec and everyone uses it, so it is worth memorising:

TokenCreated byUsed byLifetime
CREDENTIALS_TOKEN_AReceiver of the registration (party B)Initiator (party A), to call B during registrationSingle use: invalid once registration completes
CREDENTIALS_TOKEN_BInitiator (party A)Party B, to call AUntil rotated or unregistered
CREDENTIALS_TOKEN_CParty BParty A, to call BUntil rotated or unregistered

Token A is exchanged out of band, usually by email or through a partner portal, together with the versions URL. It is a bootstrap credential with access to nothing except versions and credentials. After the handshake, every request is authenticated with B or C depending on direction, and each party only ever validates tokens it generated itself.

The handshake, step by step

Say an eMSP (party A) is registering with a CPO (party B). The CPO has sent token A and its versions URL.

  1. A calls GET on B's versions URL with token A, picks 2.2.1, and calls GET on the version details URL.
  2. A generates token B and POSTs its own credentials object to B's credentials endpoint, still authenticated with token A.
  3. B, while handling that POST, calls GET on A's versions URL using token B, and fetches A's version details. If this fails, B must not complete the registration.
  4. B generates token C and returns its own credentials object (containing token C) in the response to the POST. Token A is now invalid.
  5. From here on A calls B with token C and B calls A with token B.

The POST from step 2 looks like this:

http
POST /ocpi/2.2.1/credentials HTTP/1.1
Host: cpo.example.com
Authorization: Token <base64 of CREDENTIALS_TOKEN_A>
Content-Type: application/json
X-Request-ID: 6c1e9a5e-3f2b-4a5e-9d55-6a1f2f8e0c11
X-Correlation-ID: 0b7c3a9e-1d4f-4e2a-8c6b-2a9d5e7f1b30
json
{
  "token": "ebf3b399-779f-4497-9b9d-ac6ad3cc44d2",
  "url": "https://emsp.example.com/ocpi/versions",
  "roles": [
    {
      "role": "EMSP",
      "party_id": "EXA",
      "country_code": "NL",
      "business_details": {
        "name": "Example eMSP",
        "website": "https://emsp.example.com"
      }
    }
  ]
}

The response from the CPO carries token C and the CPO's own roles:

json
{
  "data": {
    "token": "9e80a59c-6a2f-4b5d-8e1c-2f3a4b5c6d7e",
    "url": "https://cpo.example.com/ocpi/versions",
    "roles": [
      {
        "role": "CPO",
        "party_id": "CPX",
        "country_code": "DE",
        "business_details": { "name": "Example CPO" }
      }
    ]
  },
  "status_code": 1000,
  "timestamp": "2026-10-26T09:13:02Z"
}

The roles array is a 2.2 addition. In 2.1.1 the credentials object had top-level party_id, country_code and business_details and no roles at all, so a 2.1.1 party could only ever represent one party ID.

Updating and unregistering

  • GET on credentials returns the credentials the caller is currently using; useful as a connectivity check.
  • PUT replaces the credentials. Use it to rotate tokens, to switch to a newer version after an upgrade, or to change the versions URL. The same callback dance happens: the receiver fetches your versions again and returns a fresh token.
  • DELETE unregisters. Both tokens become invalid and the connection is gone.
  • A POST from a party that is already registered should be rejected with HTTP 405 Method Not Allowed; use PUT instead.

If the receiver cannot use the initiator's versions URL during the callback, the spec's status codes describe why: 3001 (unable to use the client's API), 3002 (unsupported version) and 3003 (no matching endpoints, or expected endpoints missing). Return them instead of a generic error; they make support conversations much shorter. All status codes are covered in Part 3.

The Authorization header: 2.1.1 vs 2.2

Every OCPI request carries the token as Authorization: Token <value>. The difference between versions is what <value> is:

VersionHeader value for token ebf3b399-779f-4497-9b9d-ac6ad3cc44d2
2.1.1Token ebf3b399-779f-4497-9b9d-ac6ad3cc44d2
2.2 / 2.2.1Token ZWJmM2IzOTktNzc5Zi00NDk3LTliOWQtYWM2YWQzY2M0NGQy

From 2.2 onward the token is Base64-encoded in the header. This is the single most common cause of 401 Unauthorized on a new 2.2.1 connection. It is made worse by a chicken-and-egg problem: when you first call a partner's versions endpoint, you do not yet know which version they will settle on, so you cannot be sure which encoding they expect.

Other gotchas

  • Token A reuse. Token A must stop working after registration. Partners occasionally retry the whole handshake with the old token A after a failure; return a clear error rather than half-registering them.
  • Callback timeouts. The receiver does outbound HTTP calls while the POST is in flight. If the initiator's versions endpoint is slow or behind a firewall that does not yet allow the receiver's IPs, the POST times out. Allowlisting both directions before the handshake avoids an afternoon of confusion.
  • Trailing slashes and URL normalisation. Use URLs exactly as published. Some servers treat /locations and /locations/ as different routes.
  • Token storage. Tokens are bearer secrets. Store them encrypted, never log full values, and rotate with PUT if one leaks.
  • Multiple roles. If the partner returns several roles in roles, each country_code/party_id pair is a separate party for routing and data ownership. Do not collapse them into one.

Key takeaways

  • Discovery is GET versions, then GET version details; store module URLs per partner and use them verbatim.
  • Token A bootstraps, B is what the receiver uses to call you, C is what you use to call the receiver. Each party validates only tokens it issued.
  • In 2.2.1 the token is Base64-encoded in the header; in 2.1.1 it is not. Be lenient when receiving.
  • Use PUT to rotate or upgrade, DELETE to unregister, and return the 3xxx status codes when a callback fails.

Next: Part 3, the transport layer: response envelopes, status codes, pagination and hub routing headers.

Let's Talk

Ready to build something that lasts?

Whether it's a charging network, an AI rollout, or a core enterprise system — let's talk about what you're building.