Before two OCPI parties can exchange any business data (locations, sessions, CDRs, anything), they have to set up the connection. The Credentials and Versions modules are how that happens.
Neither module carries business data of its own, which makes them easy to treat as boilerplate and skip. But nothing else in the protocol runs until they succeed, and a botched handshake fails in ways that look like everything except a handshake problem. If you are new to the protocol as a whole, start with what OCPI covers and come back here.
The setup problem
When a CPO and an eMSP want to roam with each other (whether directly or via a hub), they need to know two things about each other:
-
What OCPI versions and modules does the other side support? Version 2.1.1 with only Locations? 2.2.1 with the full module set? 2.3.0 with AFIR additions? Knowing this lets them negotiate the highest common version.
-
How do we authenticate API calls? Each OCPI call carries an Authorization header with a token. Both sides need to know each other’s tokens, plus how to rotate them when needed.
The Versions module handles (1). The Credentials module handles (2). Together they form the OCPI handshake.
The Versions module
The Versions module is simple in concept: each party exposes one endpoint that lists which OCPI versions it speaks. For each version, it lists the URLs where its module endpoints live.
The Versions endpoint
Every OCPI party (CPO or eMSP) exposes:
GET /ocpi/versions
Authorization: Token <credentials token>
→ returns:
{
"data": [
{
"version": "2.2.1",
"url": "https://cpo.example.com/ocpi/2.2.1"
},
{
"version": "2.3.0",
"url": "https://cpo.example.com/ocpi/2.3.0"
}
],
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-03T09:00:00Z"
}
This list tells the partner: “I support these OCPI versions; here are the URLs for the version-specific details.”
The Version Details endpoint
For each version listed above, the partner GETs the details. The example below is a CPO’s Version Details. Note the cpo.example.com URLs and that the CPO advertises itself as SENDER for the modules it owns:
GET https://cpo.example.com/ocpi/2.2.1
Authorization: Token <credentials token>
→ returns:
{
"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/2.2.1/locations"
},
{
"identifier": "sessions",
"role": "SENDER",
"url": "https://cpo.example.com/ocpi/2.2.1/sessions"
},
{
"identifier": "cdrs",
"role": "SENDER",
"url": "https://cpo.example.com/ocpi/2.2.1/cdrs"
},
{
"identifier": "tariffs",
"role": "SENDER",
"url": "https://cpo.example.com/ocpi/2.2.1/tariffs"
},
{
"identifier": "tokens",
"role": "RECEIVER",
"url": "https://cpo.example.com/ocpi/2.2.1/tokens"
},
{
"identifier": "commands",
"role": "RECEIVER",
"url": "https://cpo.example.com/ocpi/2.2.1/commands"
}
]
},
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-03T09:00:01Z"
}
Each module has its own URL. The role field says whether this party is the Sender or Receiver for that module. Which side plays which is fixed by the protocol, not chosen. Per the spec, the Sender is always the party that owns the data (the Receiver pulls from, or is pushed to by, the owner).
Here’s the full role map for a CPO ↔ eMSP connection:
| Module | CPO is | eMSP is |
|---|---|---|
| Locations | Sender | Receiver |
| Sessions | Sender | Receiver |
| CDRs | Sender | Receiver |
| Tariffs | Sender | Receiver |
| Tokens | Receiver | Sender |
| Commands | Receiver | Sender |
| Charging Profiles | Receiver | Sender¹ |
| Hub Client Info | Receiver² | Receiver² |
| Credentials | n/a³ | n/a³ |
¹ For Charging Profiles the Sender is a Smart Charging Service Provider (SCSP), a role an eMSP or a dedicated smart-charging party can hold. The CPO always receives. ² Hub Client Info only appears when a hub sits in the middle: the hub is the Sender, and both connected parties are Receivers. ³ Credentials (and the Versions discovery endpoint) are implemented identically by every party. The spec states the role field “is not relevant, as this module is the same for all roles,” so it’s the one module listed just once, with no Sender/Receiver split.
The pattern is easy to remember: the CPO owns the physical infrastructure, so it’s the Sender for everything about charging: Locations, Sessions, CDRs, Tariffs. The eMSP owns the driver relationship, so it’s the Sender for Tokens and for the Commands that remotely start and stop a charge.
The Credentials module
Authentication in OCPI uses bearer tokens. Every API call carries an Authorization: Token <secret> header. The Credentials module is how parties initially exchange those tokens.
The credential exchange, step by step
The exchange follows a specific sequence to allow each party to set up auth in their own infrastructure:
Step 1: Party A (let’s say a CPO) generates an initial token (Token A) out-of-band. Through email, a signed agreement, or a secure portal, but always somewhere outside OCPI. Party A gives this Token A to Party B (the eMSP) along with their /ocpi/versions URL.
Step 2: Party B uses Token A to fetch Party A’s Versions and Version Details endpoints. Now Party B knows what URLs and modules Party A supports.
Step 3: Party B generates Token B and POSTs it to Party A’s Credentials endpoint:
POST https://cpo.example.com/ocpi/2.2.1/credentials
Authorization: Token <Token A>
{
"token": "<Token B>", // Party B's new token for Party A to use
"url": "https://emsp.example.com/ocpi/versions",
"roles": [
{
"role": "EMSP",
"business_details": {
"name": "The Mobility Network",
"website": "https://themobility.network",
"logo": {
"url": "https://themobility.network/assets/logo.png",
"thumbnail": "https://themobility.network/assets/logo-thumb.png",
"category": "OPERATOR",
"type": "png",
"width": 512,
"height": 512
}
},
"party_id": "TNM",
"country_code": "NL"
}
]
}
Step 4: Party A responds with Token C and Party A’s own details:
{
"data": {
"token": "<Token C>", // Party A's new token for Party B to use
"url": "https://cpo.example.com/ocpi/versions",
"roles": [
{
"role": "CPO",
"business_details": {
"name": "Fast Charge Networks",
"website": "https://fastcharge.example.com",
"logo": {
"url": "https://fastcharge.example.com/brand/logo.svg",
"thumbnail": "https://fastcharge.example.com/brand/logo-64.png",
"category": "OPERATOR",
"type": "svg",
"width": 240,
"height": 80
}
},
"party_id": "ABC",
"country_code": "NL"
}
]
},
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-03T09:00:02Z"
}
Step 5: Token A is now invalidated. Going forward:
- Party A uses Token B to authenticate calls to Party B
- Party B uses Token C to authenticate calls to Party A
This three-token dance (A → B → C) ensures both sides have control over their own authentication and that the initial out-of-band token (A) is short-lived.
sequenceDiagram
participant A as Party A (CPO)
participant B as Party B (eMSP)
Note over A,B: Out-of-band: signed agreement
A->>B: Token A + /ocpi/versions URL<br/>(email or portal)
B->>A: GET /ocpi/versions (auth: Token A)
A-->>B: Supported versions list
B->>A: GET /ocpi/2.2.1 (auth: Token A)
A-->>B: Module endpoints
B->>A: POST /credentials<br/>Token B + B's roles
A-->>B: Response: Token C + A's roles
Note over A,B: Token A invalidated
Note over A,B: A→B calls use Token B<br/>B→A calls use Token C
Token rotation
The Credentials module supports rotation. Either party can update their token by PUTing a new credentials object. The old token is invalidated; the new one is used going forward.
In practice, token rotation happens:
- After a security event (suspected compromise)
- On whatever schedule your own security policy sets, since OCPI puts no expiry on a credentials token and neither side can force the other to rotate
- During credential migration to a new system
Modules within the Version Details
The Version Details response lists all modules each party supports. Standard module identifiers:
credentialsversionslocationssessionscdrstariffstokenscommandschargingprofiles(smart charging)hubclientinfo(for hubs)
Not every party implements every module. An eMSP might not implement chargingprofiles. A small CPO might not implement commands. The Version Details endpoint is how each party publishes what they actually do.
How this all connects
The full setup flow when two parties (say a CPO and an eMSP) want to roam:
-
Out-of-band: they sign a roaming agreement. The CPO emails the eMSP a one-time Token A and the
/ocpi/versionsURL. -
eMSP fetches Versions: uses Token A to call
GET /ocpi/versions. Learns the CPO supports 2.1.1 and 2.2.1. -
eMSP picks the highest common version: the eMSP also supports 2.2.1, so that’s the version they’ll use. Note that 2.2 and 2.2.1 are separate VersionNumbers in this list, not the same thing rounded differently, and a party can advertise one without the other. If the negotiation lands on 2.2 rather than 2.2.1, the differences between those two releases change what the rest of your integration can assume.
-
eMSP fetches Version Details: calls
GET /ocpi/2.2.1to get the list of module endpoints. -
eMSP POSTs credentials: generates Token B and POSTs it to the CPO with their own details.
-
CPO responds with Token C: the CPO generates Token C and responds.
-
The connection is established. From now on, every API call carries either Token C (eMSP→CPO) or Token B (CPO→eMSP): each side authenticates with the token the other side issued it. The initial Token A is invalidated.
-
They start exchanging real data: Locations, Tariffs, Sessions, CDRs, Tokens, Commands, whichever modules both parties support.
A complete worked example: the full handshake, wire by wire
The snippets above are trimmed for clarity. Here is the whole handshake end to
end: every request, every response, and the details that trip people up
called out in the comments. Party A is the CPO; Party B is the eMSP. (Comments
use // and # for teaching; real JSON has no comments.)
1. eMSP fetches the CPO’s supported versions (GET)
# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# Out-of-band, the CPO already handed the eMSP a one-time registration token
# (Token A) and its /ocpi/versions URL, by signed email or a portal, never
# over OCPI. The eMSP's first move is to discover which versions the CPO speaks.
GET /ocpi/versions HTTP/1.1
Host: cpo.example.com
# Auth scheme is the literal word "Token" (NOT "Bearer"), then the token value.
# In OCPI 2.2+ the token VALUE is base64-encoded; here that is Token A, the
# single-use registration token. This is the ONLY thing Token A is used for.
Authorization: Token <base64(Token A)>
# Recommended so both sides can trace one call across their logs; echo them back.
X-Request-ID: 12345
X-Correlation-ID: 67890
# ── CPO ──▶ eMSP (the response) ─────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 12345
X-Correlation-ID: 67890
{
"data": [ // array of Version objects, one per version the CPO speaks
{
"version": "2.1.1", // required: VersionNumber the CPO supports
"url": "https://cpo.example.com/ocpi/2.1.1" // required: where THAT version's details live
},
{
"version": "2.2.1", // required: VersionNumber the CPO supports
"url": "https://cpo.example.com/ocpi/2.2.1" // required: where THAT version's details live
},
{
"version": "2.3.0", // required: VersionNumber the CPO supports
"url": "https://cpo.example.com/ocpi/2.3.0" // required: where THAT version's details live
}
// Each element has exactly two fields (version, url), both required. Nothing
// else lives on a Version object. The eMSP picks the highest version BOTH speak.
],
// 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, say, status_code 2001 (invalid parameters), so read the
// envelope and never trust the HTTP status alone. 2xxx = client, 3xxx = server.
"status_code": 1000,
"status_message": "Success",
// RFC 3339, UTC ("Z").
"timestamp": "2026-07-03T09:00:00Z"
}
The eMSP picks the highest version both sides support and follows that version’s
url.
2. eMSP fetches the version details (GET)
# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# The URL comes straight from the "url" in the previous response. Do NOT
# hand-build it. This returns the per-module endpoints for version 2.2.1.
GET /ocpi/2.2.1 HTTP/1.1
Host: cpo.example.com
# Still Token A; the credentials handshake below has not happened yet.
Authorization: Token <base64(Token A)>
# ── CPO ──▶ eMSP (the response) ─────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": { // a VersionDetails object
"version": "2.2.1", // required: the VersionNumber these endpoints belong to
"endpoints": [ // required: one Endpoint per module/interface-role the CPO exposes
{
"identifier": "credentials", // required: ModuleID; the module we POST to next
"role": "SENDER", // required: InterfaceRole. For credentials the role is NOT meaningful (spec: "the same for all roles"); it's listed once, and SENDER here is just a conventional placeholder
"url": "https://cpo.example.com/ocpi/2.2.1/credentials" // required: where to reach this module
},
{
"identifier": "locations", // required: ModuleID
"role": "SENDER", // required: the CPO is the Sender for locations (it owns the data)
"url": "https://cpo.example.com/ocpi/2.2.1/locations"
},
{
"identifier": "sessions", // required: ModuleID
"role": "SENDER", // required: the CPO is the Sender for sessions
"url": "https://cpo.example.com/ocpi/2.2.1/sessions"
},
{
"identifier": "cdrs", // required: ModuleID
"role": "SENDER", // required: the CPO is the Sender for CDRs
"url": "https://cpo.example.com/ocpi/2.2.1/cdrs"
},
{
"identifier": "tariffs", // required: ModuleID
"role": "SENDER", // required: the CPO is the Sender for tariffs
"url": "https://cpo.example.com/ocpi/2.2.1/tariffs"
},
{
"identifier": "tokens", // required: ModuleID
"role": "RECEIVER", // required: the CPO RECEIVES tokens (the eMSP owns them, so it is the Sender)
"url": "https://cpo.example.com/ocpi/2.2.1/tokens"
},
{
"identifier": "commands", // required: ModuleID
"role": "RECEIVER", // required: the CPO RECEIVES remote commands (start/stop/reserve)
"url": "https://cpo.example.com/ocpi/2.2.1/commands"
},
{
"identifier": "chargingprofiles", // required: ModuleID (smart charging)
"role": "RECEIVER", // required: the CPO RECEIVES charging-profile requests
"url": "https://cpo.example.com/ocpi/2.2.1/chargingprofiles"
}
// Every Endpoint has exactly three required fields (identifier, role, url) and
// nothing else. A single module can appear TWICE (once as SENDER and once as
// RECEIVER) when a party implements both interfaces; this CPO does not, so
// each module is listed once. hubclientinfo would appear here only for a hub.
]
},
"status_code": 1000, // again: check this, not just the HTTP 200
"status_message": "Success",
"timestamp": "2026-07-03T09:00:01Z"
}
3. eMSP POSTs its credentials to the CPO (the token exchange)
# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# This is the heart of the handshake. The eMSP sends the CPO a NEW token
# (Token B) for the CPO to use on future calls TO the eMSP, plus the eMSP's own
# /ocpi/versions URL so the CPO can discover the eMSP in turn.
POST /ocpi/2.2.1/credentials HTTP/1.1
Host: cpo.example.com
# Still authenticated with Token A. This POST is the LAST time Token A is valid.
Authorization: Token <base64(Token A)>
Content-Type: application/json
{
"token": "<Token B>", // required: eMSP's token; the CPO will send THIS on calls to the eMSP
"url": "https://emsp.example.com/ocpi/versions", // required: the eMSP's OWN versions URL (gotcha), not the CPO's
"roles": [ // required: array of CredentialsRole; one per role this party plays
{
"role": "EMSP", // required: CPO | EMSP | HUB | NAP | NSP | OTHER | SCSP
"business_details": { // required: BusinessDetails
"name": "The Mobility Network", // required: display name (max 100 chars)
"website": "https://themobility.network", // optional: the party's website URL
"logo": { // optional: an Image object; shown here in full
"url": "https://themobility.network/assets/logo.png", // required within Image
"thumbnail": "https://themobility.network/assets/logo-thumb.png", // optional: smaller preview
"category": "OPERATOR", // required within Image: CHARGER | ENTRANCE | LOCATION | NETWORK | OPERATOR | OTHER | OWNER
"type": "png", // required within Image: file extension, e.g. png | jpeg | gif | svg
"width": 512, // optional: pixel width
"height": 512 // optional: pixel height
}
},
"party_id": "TNM", // required: the eMSP's party id (CiString, 3 chars)
"country_code": "NL" // required: ISO 3166-1 alpha-2 (CiString, 2 chars)
}
// A party that plays multiple roles (e.g. CPO + EMSP) lists one CredentialsRole
// per role in this array; this eMSP plays a single role, so there is one entry.
]
}
# ── CPO ──▶ eMSP (the response) ─────────────────────────────────────────────
# The response body is the OTHER party's credentials: the CPO hands back its
# OWN new token (Token C) and its OWN roles/url. This single response completes
# the exchange.
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"token": "<Token C>", // required: CPO's token; the eMSP will send THIS on calls to the CPO
"url": "https://cpo.example.com/ocpi/versions", // required: the CPO's own versions URL
"roles": [ // required: array of CredentialsRole
{
"role": "CPO", // required: CPO | EMSP | HUB | NAP | NSP | OTHER | SCSP
"business_details": { // required: BusinessDetails
"name": "Fast Charge Networks", // required: display name (max 100 chars)
"website": "https://fastcharge.example.com", // optional: the party's website URL
"logo": { // optional: an Image object; shown here in full
"url": "https://fastcharge.example.com/brand/logo.svg", // required within Image
"thumbnail": "https://fastcharge.example.com/brand/logo-64.png", // optional: smaller preview
"category": "OPERATOR", // required within Image: CHARGER | ENTRANCE | LOCATION | NETWORK | OPERATOR | OTHER | OWNER
"type": "svg", // required within Image: file extension
"width": 240, // optional: pixel width
"height": 80 // optional: pixel height
}
},
"party_id": "ABC", // required: the CPO's party id (CiString, 3 chars)
"country_code": "NL" // required: ISO 3166-1 alpha-2 (CiString, 2 chars)
}
]
},
"status_code": 1000, // 1000 here means the registration succeeded
"status_message": "Success",
"timestamp": "2026-07-03T09:00:02Z"
}
The moment this response is sent, Token A is invalidated. From here on: the eMSP authenticates its calls to the CPO with Token C, and the CPO authenticates its calls to the eMSP with Token B. A → B → C, and A never works again.
4. Later: rotating or tearing down the connection
# ── Rotate: replace the token you gave the other side ───────────────────────
# Authenticate with your CURRENT token, and send a fresh credentials object.
# The old token is invalidated once this returns 1000.
PUT /ocpi/2.2.1/credentials HTTP/1.1
Host: cpo.example.com
Authorization: Token <base64(current token)>
Content-Type: application/json
{
"token": "<new token>", // required: replaces the previous one
"url": "https://emsp.example.com/ocpi/versions", // required: your OWN versions URL
"roles": [ // required: a full CredentialsRole, identical shape to the POST above
{
"role": "EMSP", // required: CPO | EMSP | HUB | NAP | NSP | OTHER | SCSP
"business_details": { // required: BusinessDetails
"name": "The Mobility Network", // required: display name (max 100 chars)
"website": "https://themobility.network", // optional: the party's website URL
"logo": { // optional: an Image object
"url": "https://themobility.network/assets/logo.png", // required within Image
"thumbnail": "https://themobility.network/assets/logo-thumb.png", // optional: smaller preview
"category": "OPERATOR", // required within Image: CHARGER | ENTRANCE | LOCATION | NETWORK | OPERATOR | OTHER | OWNER
"type": "png", // required within Image: file extension
"width": 512, // optional: pixel width
"height": 512 // optional: pixel height
}
},
"party_id": "TNM", // required: party id (CiString, 3 chars)
"country_code": "NL" // required: ISO 3166-1 alpha-2 (CiString, 2 chars)
}
]
}
# ── Unregister: tear the whole connection down ──────────────────────────────
DELETE /ocpi/2.2.1/credentials HTTP/1.1
Host: cpo.example.com
Authorization: Token <base64(current token)>
# After this, both tokens are dead and the parties must handshake again from
# Token A to reconnect.
Common implementation gotchas
A few things that catch real-world implementations:
Getting the three-token ordering wrong
This is the failure that bites hardest, and it is always the same mistake underneath. Token A, the out-of-band registration token, is single-use. It exists only to bootstrap the handshake, and it MUST be invalidated the instant the credentials exchange completes. Going forward, each side uses the token the OTHER side gave it:
Out-of-band: CPO ──▶ eMSP Token A (email or portal, single-use bootstrap)
Handshake: eMSP ──▶ CPO POST /credentials { token: Token B, … }
CPO ──▶ eMSP response { token: Token C, … }
Afterwards: eMSP ──▶ CPO auth with Token C (the token the CPO gave it)
CPO ──▶ eMSP auth with Token B (the token the eMSP gave it)
Token A is now DEAD. A → B → C.
Where people go wrong:
- Reusing Token A for ongoing calls after the handshake, including for the
Versions endpoint. Token A is not “kept alive” for discovery. During
registration it authenticates exactly three calls (
GET /versions,GETthe version details, and thePOST /credentials); the instant that POST returns, it is invalidated for everything, Versions included. All later calls use Token C (eMSP→CPO) or Token B (CPO→eMSP). Reconnecting after aDELETE /credentialsneeds a fresh Token A, issued out-of-band. It’s invalidated by design: a compromise of the original email must not grant lasting API access. - Authenticating with your OWN token instead of the one the other side issued. The eMSP calls the CPO with Token C (the CPO’s token), NOT with Token B (its own). Swapping these is the classic “401 Unauthorized” cause.
- Sending the wrong
url. In the POST body you send your OWN/ocpi/versionsURL, not the other party’s. That URL is how they discover you back.
The Authorization header carries the credentials token
Every OCPI call authenticates with Authorization: Token <value>, using the
literal word Token and never Bearer. The value is whichever credentials
token applies to the direction of the call (A, then B or C). In OCPI 2.2 and
later the token value is base64-encoded before it goes in the header, and
skipping that step (or encoding twice) produces a 401 that looks nothing like an
encoding bug from the outside. Bearer-token handling and TLS expectations
go deeper than this article does.
last_updated and status_code still apply
As with every module, HTTP 200 only means the call arrived. You must ALSO check
the status_code inside the OCPI envelope (1000 = success, 2xxx = client error,
3xxx = server), and all timestamps are RFC 3339 in UTC (the trailing Z). The
full status_code taxonomy (coming soon) is worth
reading before you write your first error handler.
For the authoritative field-by-field definitions, see the OCPI 2.2.1 specification’s Credentials and Versions modules in the OCPI GitHub repository.
Real-world gotchas
Token A leakage
The out-of-band Token A is the weakest link in the chain, because it is the one secret that travels outside OCPI’s own transport. Anyone who reads it before the legitimate partner does can complete the handshake in their place, and the real partner then finds that its own Token A no longer works. The defence is to give Token A a short expiry, deliver it through a channel you control end to end rather than plain email, and treat a Token A that went unused past its window as burned rather than reissuing the same value.
Version mismatch
If two parties advertise no version in common, the handshake has nowhere to land and they cannot connect. Two ways out: upgrade one side, or put a translating intermediary between them that terminates one connection per party and speaks a different version on each. A hub is structurally able to do this because it registers with each party separately, so nothing about the pairing has to match.
Endpoint mismatch
A party can advertise a module endpoint in Version Details that its implementation does not actually serve. Calls come back 404 or 501, and because the advertisement came from the partner, your own logs make it look like your client built a bad URL. Walking every advertised endpoint once, right after registration, turns this into a five-minute finding instead of a production incident. A structured connection test covers the sequence.
Token rotation while sessions are in flight
If you rotate tokens while there are in-flight requests, some requests may fail. Best practice: graceful overlap (accept both old and new tokens for a transition window).
Hub routing complexity
When a hub sits between two parties, the credentials dance happens twice, once between each party and the hub. The hub holds independent credentials with each side, which is why a hub can terminate two connections running different OCPI versions. That independence is part of what you trade away and gain by connecting through a hub.
What the handshake leaves you holding
The credentials store is the only OCPI state you cannot rebuild from your partner. Locations, tariffs, sessions, and CDRs all live on someone else’s system too, so a bad migration is recoverable: you re-pull and carry on. Token B and Token C exist in exactly two places, and neither party can regenerate the pair alone. Lose the store and recovery runs through people. Someone issues a fresh Token A, and a partner engineer finds time to re-run registration. That puts your credentials table in the same backup tier as your billing data rather than your config.
The out-of-band step creates a second exposure that has nothing to do with your code. Because Token A arrives outside the protocol, the security boundary of your OCPI stack extends past your API into whatever mailbox or portal it lands in. An attacker who never touches your servers but reads that message can register as your partner, and the handshake will look completely normal from your side, because it is normal. Nothing in the protocol can tell the difference.
One last thing the handshake hands you is a version commitment. Every module URL you stored came out of a specific Version Details response, so the negotiated version is now embedded in your routing rather than sitting in a setting somewhere. Moving to a newer version means running a second handshake against a different version URL and cutting over once it works, which is exactly why the Versions endpoint returns a list rather than a single current version.