OCPI HubClientInfo Module: How Hub Discovery Works

How the OCPI HubClientInfo module lets a hub publish its connected parties, with worked PUT/GET examples, the four status values, and the gotchas that bite.

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 the module existed, finding out which parties a roaming hub connected on your behalf meant reading the hub’s documentation, asking their support team, or squinting at a dashboard. Now there’s a machine-readable endpoint.

The problem in one paragraph

You connect to a hub. The hub theoretically gives you access to every other party connected to it. But that set moves: parties join, parties leave, parties change their roles or their country and party identifiers. You need to know programmatically who is currently reachable, so your code routes messages to the right place and your support team can answer “do you roam with X?” without opening a ticket with the hub. 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-1 alpha-2 country code.
  • role: which role the party plays, one of CPO, EMSP, HUB, NAP, NSP, OTHER, SCSP.
  • status: one of CONNECTED, OFFLINE, PLANNED, SUSPENDED.
  • last_updated: timestamp of the last status change.

There are two interaction patterns: pull (GET all clients, GET a 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.1/hubclientinfo returns the full list of reachable clients.
  • GET /ocpi/2.2.1/hubclientinfo/{country_code}/{party_id} returns details on one client.

The version segment in that path comes out of the Versions handshake, not from the module. A party still on 2.2 calls /ocpi/2.2/hubclientinfo, a party on 2.2.1 calls /ocpi/2.2.1/hubclientinfo, and the object on the wire is the same shape either way (for the rest of the delta, see OCPI 2.2 vs 2.2.1).

The client, a CPO or eMSP connected to the hub, polls this on an interval: hourly, daily, whatever matches how fast the roster turns over. A roster entry only changes when a commercial relationship starts or ends or a party’s connection drops, so daily is usually resolution enough.

Pull is the easier of the two to stand up. It keeps the hub stateless, and you can reproduce any bug with curl.

Push-based updates

The richer pattern. The hub keeps a list of subscribers, parties that have asked for real-time updates, and PUTs to each subscriber’s HubClientInfo endpoint whenever a client’s status changes.

Push earns its keep when:

  • The hub has enough clients that re-fetching the whole list on a schedule is mostly wasted bandwidth.
  • A status change (partner X just went offline) is operationally important to act on quickly.
  • Your business logic depends on knowing about a new partner the day they go live, not the day your poller happens to notice.

Push also costs more on both sides. The hub has to hold subscriber state, retry failed deliveries, and cope with a subscriber that is down for a day. Because all that extra state sits on the hub’s side of the link, push is the capability you ask a hub for during onboarding rather than the one you assume is there.

What you do with the list

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.1/hubclientinfo and receive a list of, say, 87 ClientInfo objects: 62 CPOs and 25 other eMSPs across 15 countries. The mix is commercial rather than geographic, so European and North American parties sit in the same response body with nothing but country_code to tell them apart.

That list is then load-bearing in four different places:

  • Programmatic routing. When a user starts a session at a charger run by NL/CPO-XYZ, you check your local copy to confirm CPO-XYZ is CONNECTED and route the Session and CDR queries to the hub for them.
  • Operations dashboard. You display “currently roaming with 62 CPOs across 15 countries” and refresh it from this endpoint.
  • Alerting. If your nightly job sees a high-revenue CPO partner’s status drop to OFFLINE, you raise an alert.
  • Sales questions. Your team can answer “do you cover charging in Spain?” or “can my drivers charge in California?” from data instead of memory.

The same flow works in reverse for a CPO: GET HubClientInfo to know which eMSPs your sessions might involve, and which countries their drivers 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", followed by 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, and nothing 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), so 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, so 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 expensive misreading of this module is to treat it 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.” Whether any individual EVSE is free, charging, or broken lives in the Locations module, never here:

# 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 or 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. It is one of the most reliable OCPI integration pitfalls to walk into, and it fails silently rather than loudly.

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 part the spec doesn’t address: a hub publishing its full client list is publishing strategically valuable information.

A competitor hub can read your HubClientInfo and know your entire customer roster in one call. A prospective client can compare your roster to a rival’s and use the gaps as a negotiating point. Analysts can track market share from the outside.

The spec leaves visibility policy to the hub, and there are three postures that all satisfy it:

  • Restrict the endpoint to authenticated, known partners. The hub can scope what each caller sees to the parties they already deal with, or to parties in countries it knows they operate in.
  • Publish partial information. A country-level breakdown that names nobody still answers “do you cover Spain?” without handing over the roster.
  • Publish everything. Openness is itself a sales argument when the roster is the thing you want compared.

If you’re evaluating a hub, ask explicitly how they handle HubClientInfo visibility. The answer tells you how they think about their own market position.

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 and 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.

Offer push to the partners it matters most to. The partners routing the most traffic through you are the ones for whom a stale roster costs the most, so they are the ones worth carrying subscriber state for.

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.

Know what your transport already proves. TLS plus the OCPI token already authenticates you to the caller. If an integration team wants more than that, anything extra, such as a signature over the response body verified against a certificate you publish, sits outside the module and has to be agreed bilaterally.

A note on alternatives

HubClientInfo standardizes hub-based discovery, but other approaches exist:

  • Out-of-band documentation. A hub can publish its partner list on its website, in a sales deck, or behind a partner-portal login. That is how discovery worked before OCPI 2.2, and it still works.
  • Manual partner lists. Your eMSP maintains a hand-curated list of CPOs you accept sessions from. Slower to update, but you control exactly what is in it.
  • Bilateral discovery. For peer-to-peer partnerships, discovery is just the fact that you and they have a relationship. No protocol involved.

HubClientInfo’s value over these is automation and freshness. With one hub and five partners, a spreadsheet is fine. With two hubs and 80 partners across 20 countries, the roster stops being something a human can keep current.

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 the Credentials handshake, party by party.

What actually takes work

The HTTP here is trivial. The part that takes design work is that ClientInfo is the only OCPI object that describes a party rather than something a party owns. Locations, Sessions, CDRs, Tokens all slot into tables you already have, because you already model chargers and charging events. This one asks a question most schemas have no answer to: where does “the set of counterparties that currently exist” live in your system, and which parts of it read from there?

The second thing worth internalising is that OFFLINE and SUSPENDED are not degrees of the same condition. OFFLINE is a party whose connection is down right now and may be back in ten minutes. SUSPENDED is a party that is not coming back. Code that collapses both into “skip for now” will carry a dead party in its routing tables and its country counts indefinitely, and nothing will ever alert you, because from the consumer side a permanently absent party looks exactly like a quiet one.

Implement the client side if you connect to a hub, implement the sender side if you operate one, and skip it otherwise.

Quick check

Q1. What problem does the HubClientInfo module solve?
Q2. Which OCPI version introduced HubClientInfo?
Q3. A ClientInfo object shows a partner with status OFFLINE. What is the recommended consumer behavior?
Q4. Why might a hub choose NOT to publish parties with status PLANNED?
Q5. How does HubClientInfo differ from the Versions endpoint?

Frequently asked questions

Do I need to implement HubClientInfo if I am a CPO or eMSP, not a hub?

Only if you want it. The client side of the module is optional: you implement it to discover who is reachable through your hub. You can roam through a hub without ever calling the endpoint and keep the partner list by hand instead. Treat it as a convenience, not a blocker.

Does HubClientInfo expose business-sensitive information?

It can. A hub publishing its full client list reveals every party connected through it, which is competitively sensitive. The spec does not require the roster to be public, so a hub can scope what it returns: only the parties a given caller already deals with, or a country-level summary that names nobody.

What is the difference between HubClientInfo and the Versions endpoint?

The Versions endpoint is your party telling a partner which OCPI versions you speak. HubClientInfo is a hub telling a connected partner which OTHER parties are reachable through it. They serve completely different discovery purposes.

How often does HubClientInfo data change?

Rarely, compared with any other OCPI module. A ClientInfo record only changes when a party joins the hub, leaves it, or drops offline, so the sensible design is a cached local copy on a refresh schedule rather than a live call on every lookup.

Found this useful? Share it.