HubClientInfo is a small, simple module that solves a specific problem: when you’re connected to a hub, how do you know who else is reachable through that hub?
OCPI 2.2 introduced HubClientInfo to make hub-based roaming discoverable without out-of-band coordination. Before this module, finding out which parties a hub connects on your behalf was a manual process — read the hub’s documentation, ask their support team, or look at your dashboard. Now there’s a machine-readable endpoint.
This article covers what it does, what it looks like, and the surprisingly real tradeoffs around publishing it.
The problem in one paragraph
You connect to a hub. The hub theoretically gives you access to every other party connected to it. But “every other party” is a moving target — parties join and leave, change their country/party identifiers, change their roles. You need a way to know, programmatically, who’s currently reachable so your code can route messages correctly, your support team can answer “do you roam with X?”, and your business team can see which markets you cover. HubClientInfo is that endpoint.
What the module defines
The module is small. It has one main resource: ClientInfo objects published by the hub. Each ClientInfo represents one party reachable through the hub.
A typical ClientInfo looks roughly like:
{
"party_id": "EMS",
"country_code": "NL",
"role": "EMSP",
"status": "CONNECTED",
"last_updated": "2026-06-25T14:22:00Z"
}
The fields:
- party_id — three-character identifier for the party (assigned by their country’s NSP, or self-chosen).
- country_code — ISO 3166 alpha-2 country code.
- role — what role this party plays (
CPO,EMSP,HUB,NAP,NSP,OTHER,SCSP). - status —
CONNECTED,OFFLINE,PLANNED,SUSPENDED. - last_updated — timestamp of the last status change.
There are two interaction patterns: pull (GET all clients, GET specific client) and push (the hub PUTs updates to subscribers as they happen).
The two flows
flowchart LR
Hub[Hub<br/>roster of clients]
subgraph Pull
C1[CPO or eMSP] -->|GET hubclientinfo| Hub
Hub -->|list of clients| C1
end
subgraph Push
Hub -->|PUT status change| C2[Subscriber]
end
style Hub fill:#e3f2fd,stroke:#1565c0
Pull-based discovery
The simplest pattern. The hub exposes:
GET /ocpi/2.2/hubclientinfo— returns the full list of reachable clients.GET /ocpi/2.2/hubclientinfo/{country_code}/{party_id}— returns details on one client.
The client (a CPO or eMSP connected to the hub) polls this periodically — every hour, every day, depending on how dynamic the hub’s roster is. Most are not very dynamic, so daily polling is plenty.
This is what most implementations do because it’s straightforward, stateless on the hub’s side, and easy to debug.
Push-based updates
The richer pattern. The hub maintains a list of subscribers — parties that have indicated they want real-time updates — and PUTs updates to each subscriber’s HubClientInfo endpoint whenever a client’s status changes.
Push is useful when:
- The hub has many clients and the full list is large enough that polling is wasteful.
- A client status change (e.g., partner X just went offline) is operationally important to act on quickly.
- The subscriber’s business logic depends on knowing about new partners as soon as they’re available (for marketing, dashboards, etc.).
Push is more complex on both sides — the hub must maintain subscriber state, retry failed pushes, deal with subscriber outages. Most hubs offer pull as default and push as an opt-in capability.
A real-world example
Imagine you’re an eMSP in the Netherlands. You connect to a hub whose roster spans both Europe and North America. After your Credentials handshake completes, you GET /ocpi/2.2/hubclientinfo and receive a list of, say, 87 ClientInfo objects: 62 CPOs and 25 other eMSPs across 15 countries — networks like a German fast-charge operator, a US station operator, and a Canadian utility-run CPO all in the same list.
What you do with that list:
- Programmatic routing. When a user starts a session at a charger run by
NL/CPO-XYZ, you check your local copy of HubClientInfo to confirm CPO-XYZ is connected (status:CONNECTED) and route the Session/CDR queries to the hub for them. - Operations dashboard. You display “currently roaming with 62 CPOs across 15 countries” with a refresh from this endpoint.
- Alerting. If your nightly job sees that a high-revenue CPO partner’s status dropped to
OFFLINE, you raise an alert. - Sales/Marketing. Your sales team can answer “do you cover charging in Spain?” or “can my drivers charge in California?” with confidence.
The same flow works for a CPO: GET HubClientInfo to know which eMSPs your sessions might involve, and which countries their users typically come from.
A complete worked example
The snippet earlier is trimmed for clarity. Here is the full round trip in both
directions — the hub PUSHING a status change to a party, and a party READING the
whole roster back — with the wire-level details that trip people up called out in
the comments. (Comments use // and # for teaching; real JSON has no comments.)
Remember the topology: HubClientInfo only exists behind a hub. The Sender is always the hub; the Receiver is each connected party. There is no HubClientInfo on a peer-to-peer OCPI link.
1. The hub pushes a status change (PUT to the party)
# ── Hub ──▶ party ───────────────────────────────────────────────────────────
# The hub owns the roster, so it PUSHES the ClientInfo to the party's Receiver
# Interface. PUT = "here is the current, authoritative status for this one
# party; replace whatever you hold for it." One ClientInfo per PUT, addressed by
# the {country_code}/{party_id} of the party the status is ABOUT.
PUT /ocpi/2.2.1/hubclientinfo/DE/ALL HTTP/1.1
Host: cpo.example.com
# Auth scheme is the literal word "Token" — NOT "Bearer" — then the credentials
# token the party issued to the HUB during the Credentials handshake.
Authorization: Token <token-the-party-issued-to-the-hub>
Content-Type: application/json
# Recommended so both sides can trace one call across their logs; echo them back.
X-Request-ID: 12345
X-Correlation-ID: 67890
# The URL path carries {country_code}/{party_id} — the party this ClientInfo is
# ABOUT (here DE/ALL). They MUST equal the country_code / party_id in the body;
# a mismatch is a common reject.
{
"country_code": "DE", // ISO 3166-1 alpha-2 of the party (2 chars)
"party_id": "ALL", // the party's party id (3 chars)
"role": "CPO", // one of: CPO | EMSP | HUB | NAP | NSP | OTHER | SCSP
// status is the whole point of this module: it reports whether this PARTY is
// reachable through the hub RIGHT NOW — not anything about chargers.
// CONNECTED → reachable; route requests for this party to the hub
// OFFLINE → currently unreachable; skip / defer routing to it
// PLANNED → connection planned but never established yet (onboarding)
// SUSPENDED → no longer active; will never connect again (permanent)
"status": "OFFLINE",
// RFC 3339, UTC ("Z"). Receivers use last_updated to detect real changes and
// to resolve out-of-order updates (newest wins). Forget to bump it on a change
// and your local roster silently keeps a stale status — a classic, painful bug.
"last_updated": "2026-07-03T09:00:00Z"
}
# ── party ──▶ Hub (the response) ────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 12345
X-Correlation-ID: 67890
{
"data": {}, // for a PUT, receivers usually echo nothing
// 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 status_code 2001 (invalid parameters) — always read the
// envelope, never trust the HTTP status alone. 2xxx = client error, 3xxx = server.
"status_code": 1000,
"status_message": "Success",
"timestamp": "2026-07-03T09:00:01Z"
}
2. The party reads the whole roster back (GET from the hub)
# ── party ──▶ Hub ───────────────────────────────────────────────────────────
# The party reads from the hub's Sender Interface to get every connected party's
# current status. Typical flow: one GET to seed your local roster (below), then
# rely on the hub's pushed PUTs — or a daily/weekly re-GET — for changes. The
# list endpoint is paginated: follow the pages or you only ever see the first slice.
GET /ocpi/2.2.1/hubclientinfo?date_from=2026-07-01T00:00:00Z&offset=0&limit=50 HTTP/1.1
Host: hub.example.com
# Opposite direction, opposite token: the one the hub issued to THIS party.
Authorization: Token <token-the-hub-issued-to-this-party>
# Query params:
# date_from / date_to → only ClientInfo objects changed in this window (delta sync)
# offset / limit → paging; the hub may cap limit — read the real one back
# from the response headers below.
# ── Hub ──▶ party (the response) ────────────────────────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
# Pagination lives in HEADERS, not the body:
# Link → URL of the NEXT page; keep following until there is no "next"
# X-Total-Count → total objects matching the filter, across ALL pages
# X-Limit → the page size the hub actually applied (can be < your limit)
Link: <https://hub.example.com/ocpi/2.2.1/hubclientinfo?date_from=2026-07-01T00:00:00Z&offset=50&limit=50>; rel="next"
X-Total-Count: 87
X-Limit: 50
{
"data": [ // an ARRAY of ClientInfo objects (this page only)
{
"country_code": "NL",
"party_id": "TNM",
"role": "CPO",
"status": "CONNECTED", // reachable through the hub right now
"last_updated": "2026-07-02T18:05:00Z"
},
{
"country_code": "US",
"party_id": "EVG",
"role": "CPO",
"status": "CONNECTED",
"last_updated": "2026-06-30T11:40:00Z"
},
{
"country_code": "DE",
"party_id": "ALL",
"role": "CPO",
"status": "OFFLINE", // the same party the PUT above just updated
"last_updated": "2026-07-03T09:00:00Z"
}
// … up to `limit` ClientInfo objects, then follow the Link header for the next page.
],
"status_code": 1000, // again: check this, not just the HTTP 200
"status_message": "Success",
"timestamp": "2026-07-03T09:05:00Z"
}
Common implementation gotchas
A few things that catch real-world implementations:
It reports PARTY connectivity, not charger availability — the #1 mistake
The single most common misunderstanding is treating HubClientInfo as if it says something about charging. It does not. It answers exactly one question: is party X reachable through this hub right now? Nothing more.
A ClientInfo status of CONNECTED means “the hub can route messages to CPO X”;
OFFLINE means “it currently cannot.” It says nothing about whether any EVSE is
free, charging, or broken:
# WRONG — inferring EVSE availability from HubClientInfo
if clientinfo["NL/TNM"].status == "CONNECTED":
show_chargers_as_available() # ❌ CONNECTED ≠ any charger is free
# RIGHT — status gates ROUTING; charger state comes from the Locations module
if clientinfo["NL/TNM"].status == "CONNECTED":
route_locations_query_to_hub("NL", "TNM") # ask the CPO via Locations
# EVSE availability lives in Locations (AVAILABLE / CHARGING / OUTOFORDER / …),
# never here.
So the status field really drives one thing on the consumer side: whether
you bother routing a request to that party at all. If a party is OFFLINE,
skip or defer the call rather than letting it time out. (This party-versus-charger
split is a good example of where OCPI sits relative to OCPP and ISO 15118:
OCPI moves data between operators, while charger-level state comes from elsewhere.)
And the topology caveat that surprises people: this module only exists behind a hub. In a peer-to-peer OCPI link there is no hub to publish a roster, so there is no HubClientInfo at all — you simply know your one direct partner.
For the exact field definitions and interface behavior, see the OCPI 2.2.1 specification’s HubClientInfo module.
last_updated is critical
Receivers use last_updated to detect when a party’s status has actually
changed and to resolve out-of-order pushes (newest wins). Implementations that
forget to bump it on every change leave downstream rosters showing a stale
CONNECTED/OFFLINE — and you route to a party that is no longer reachable.
Check the envelope, not just the HTTP status
As in every OCPI module, an HTTP 200 only means the call arrived. A push can
still fail validation and come back 200 with status_code 2001. Always read
status_code inside the envelope: 1000 is success, 2xxx is a client error,
3xxx is a server error.
Don’t make it the source of truth for routing
HubClientInfo is a discovery aid, not a routing authority. Treat a status change as an event to log and possibly alert on, but let the hub actually accepting your Session or CDR message be the real signal — don’t hard-block traffic on a possibly-stale cache.
The competitive sensitivity problem
Here is the awkward truth that the spec doesn’t directly address: a hub publishing its full client list is publishing strategically valuable information.
A competitor hub can read your HubClientInfo and immediately know your entire customer roster. A potential client can compare your roster to a competitor’s and use it as a negotiating point. Investors and analysts can track market share from afar.
In practice, hubs handle this in different ways:
- Some restrict HubClientInfo to authenticated and known partners only — even though OCPI doesn’t strictly require this, the hub can scope visibility to “only the parties you yourself are connected to” or “only parties in countries we know you operate in.”
- Some publish only partial information — country breakdown without naming specific parties, or naming the largest parties only.
- Some implement it fully transparently because their market position favors openness.
If you’re evaluating a hub, ask explicitly how they handle HubClientInfo visibility. The answer reveals something about their philosophy.
Implementation notes for CPOs and eMSPs
If you’re not a hub but you’re consuming HubClientInfo, a few practical recommendations.
Cache the data. You don’t need fresh-on-every-query. Cache for an hour, refresh in the background.
Build a local view that crosses multiple hubs. If you connect to two hubs and one P2P partner, your operational picture wants to be “the union of all reachable parties.” Don’t query the hubs every time someone asks; aggregate into your own DB.
Treat status changes as events. When you refresh and see a previously CONNECTED partner is now OFFLINE, that’s an actionable event. Log it. Alert if it’s a strategic partner. Resume traffic when they’re back.
Don’t make HubClientInfo the source of truth for routing. It’s a discovery aid. Your actual Session and CDR routing should not block on a stale HubClientInfo cache — if the hub accepts your message, that’s the truth.
Don’t expect anything beyond the five fields. The ClientInfo object is fixed: party_id, country_code, role, status, and last_updated — all five required, nothing else. It carries no business_details, no capabilities, and no list of supported modules. If you need a party’s business details or which modules they speak, that comes from the Credentials and Versions handshake with that party, not from HubClientInfo. Don’t engineer against fields that this object does not define.
Implementation notes for hubs
If you ARE a hub, a few things to think about.
Decide your visibility policy first. Public list, partner-only list, scoped list? Decide and document it.
Make status changes timely. A party that has been offline for an hour should be reflected in your HubClientInfo within minutes, not days. Your value as a hub is partly that your operational state is accurate.
Implement the push pattern for power users. Your largest partners (the ones who route the most traffic through you) want push, not pull. Supporting push for them improves their experience and your stickiness.
Be careful with the “PLANNED” status. This indicates a party that’s in the onboarding pipeline but not yet live. Publishing PLANNED parties leaks future-roster information; consider whether you want to.
Sign your responses if your partners care. Some hubs sign their HubClientInfo responses with their server’s certificate so the consumer can verify authenticity. Optional but appreciated by paranoid integration teams.
A note on alternatives
HubClientInfo standardizes hub-based discovery, but other approaches exist:
- Out-of-band documentation. Many hubs publish their partner list on their website, in a sales doc, or via a partner portal. This is how it worked before OCPI 2.2 and still works.
- Manual partner lists. Your eMSP maintains a hand-curated list of CPOs you accept sessions from. Slower to update but gives you full control.
- Bilateral discovery. For peer-to-peer partnerships, discovery is just “you and they have a relationship” — no protocol involvement needed.
HubClientInfo’s value over these is automation and freshness. If you have one hub and 5 partners, manual is fine. If you have two hubs and 80 partners across 20 countries, automation is the only way.
What this module is NOT
Two clarifications:
It is not a market directory of all OCPI parties globally. It’s specific to one hub’s connected clients. The broader “who is doing OCPI” picture is not standardized; that’s still a market-research problem.
It is not a Capabilities discovery mechanism. Knowing that party X is connected doesn’t tell you which OCPI modules they support. You discover module support through the Versions endpoint after Credentials handshake, party by party.
The honest summary
HubClientInfo is a small but useful piece of OCPI 2.2. It standardizes something that used to be ad-hoc. It removes a manual step in the hub-mediated roaming experience. It’s not a blocker for any party to ignore — you can roam through a hub without ever using HubClientInfo — but if you’re operating at scale, having a programmatic view of hub-connected parties is operationally valuable. Implement the client side if you connect to a hub; implement the server side if you operate one; don’t worry about it otherwise.