OCPI ChargingProfiles: Smart Charging via Roaming Explained

How OCPI ChargingProfiles lets an eMSP shape smart charging on a live session: the fields, a full worked example, common gotchas, and why adoption lags.

ChargingProfiles is one of the two new modules OCPI 2.2 introduced (the other being HubClientInfo). It defines a way for an eMSP to ask a CPO to shape the power profile of an ongoing or upcoming charging session — limiting maximum power, scheduling charging within a time window, even (in theory) requesting bidirectional flows.

It is also, today, one of the least-implemented modules in production. This article explains what it does, why it matters, and the realistic state of its adoption.

The use case it solves

Smart charging is a real problem with real money behind it. The classic scenarios:

  • Demand-response participation. A grid operator pays your eMSP to reduce load during peak hours. Your eMSP wants to pause or throttle EV charging across its user base during that window.
  • Time-of-use optimization. An EV plugged in at 6pm doesn’t need to be at 100% until 7am. Charging slowly through the night (or only during cheap-electricity hours) saves money and reduces grid stress.
  • Renewable matching. A user wants their car to charge when local solar output is high. The eMSP can adjust the charging rate against local generation forecasts.
  • Vehicle-to-grid (V2G) and vehicle-to-home (V2H). The car discharges back to the grid or building during peak hours, then refills at off-peak.

All of these need a way for the entity managing the user’s preferences (the eMSP) to influence what the entity controlling the hardware (the CPO) actually delivers to the session. If the CPO/eMSP roles here are unfamiliar, the OCPI primer covers the actors and the four-corner model. Before OCPI 2.2, there was no standard for this — it happened, but only through bilateral integrations or vertically-integrated single-operator deployments.

ChargingProfiles is the standardization layer.

What ChargingProfiles actually defines

The module has three main operations:

  1. PUT a charging profile for a specific session or for a location’s general default.
  2. GET the active charging profile for a session.
  3. DELETE (clear) a charging profile.

Plus an async notification flow for the CPO to inform the eMSP when a profile takes effect, is updated, or is rejected.

The data model — the ChargingProfile object — is heavily inspired by OCPP’s smart-charging structure (which makes sense, because the CPO is ultimately translating this into an OCPP SetChargingProfile message to the actual charge point; the OCPI vs OCPP vs ISO 15118 breakdown covers where each protocol sits in the stack). The key fields:

  • start_date_time — when the profile takes effect.
  • duration — how long it applies.
  • charging_rate_unitW (watts) or A (amps). Pick one and stay consistent.
  • min_charging_rate — lower bound on power (optional).
  • charging_profile_period — an array of {start_period, limit} pairs describing the schedule (e.g. “from t+0 charge at 11kW, from t+3600s charge at 7kW, from t+10800s charge at 22kW”).

A typical request might look like:

{
  "start_date_time": "2026-06-26T22:00:00Z",
  "duration": 28800,
  "charging_rate_unit": "W",
  "charging_profile_period": [
    { "start_period": 0,     "limit": 7000  },
    { "start_period": 14400, "limit": 11000 },
    { "start_period": 25200, "limit": 22000 }
  ]
}

That says: “Starting at 10pm UTC, charge at 7kW for the first 4 hours, then 11kW for the next 3 hours, then 22kW for the final hour.” If we assume a typical 70kWh EV at 40% state of charge, that profile delivers roughly the energy needed to reach 90% by 6am — but does so by drawing low power during the system peak hours and ramping up overnight.

The session-specific flow

A typical interaction:

  1. User plugs in at a CPO-operated charger.
  2. CPO sends Session START to the user’s eMSP (via the OCPI Sessions module).
  3. eMSP recognizes “this is one of our users — what should the profile be?” and looks up the user’s preferences plus current grid conditions.
  4. eMSP PUTs a ChargingProfile for that session ID to the CPO.
  5. CPO translates that into an OCPP SetChargingProfile message to the actual charge point.
  6. Charge point applies the profile. Charging rate now follows the schedule.
  7. eMSP can update the profile mid-session if conditions change (price spike, user override).
  8. When the session ends, the profile is naturally cleared.
sequenceDiagram
    participant U as Driver
    participant CP as Charge Point
    participant CPO as CPO
    participant EMSP as eMSP
    U->>CP: Plug in
    CP->>CPO: Session start
    CPO->>EMSP: Session START
    EMSP->>EMSP: Look up prefs<br/>and grid signals
    EMSP->>CPO: PUT ChargingProfile
    CPO->>CP: OCPP SetChargingProfile
    CP-->>CPO: Applied
    CPO-->>EMSP: Profile active
    Note over EMSP,CP: eMSP may PUT<br/>updates mid-session

The flow looks clean on paper. In practice it has many failure modes.

A complete worked example

The request snippet above is trimmed to the profile object. Here is the full round trip for each direction — the eMSP setting a profile, the CPO’s two-part answer, and the eMSP asking for the active profile — with the wire-level details that trip people up called out in the comments. (Comments use // and # for teaching; real JSON has no comments.)

ChargingProfiles is asynchronous, exactly like the Commands module. Every call is keyed by session_id, and every call carries a response_url. The response you get on the HTTP connection is only an acknowledgement; the real outcome arrives later as a separate POST to that response_url.

1. The eMSP sets a profile (PUT to the CPO)

# ── eMSP / SCSP ──▶ CPO ─────────────────────────────────────────────────────
# The Sender here is the eMSP (or a Smart Charging Service Provider acting for
# it). It PUTs the profile to the CPO's Receiver Interface, keyed by session_id.
# The path id is the SAME session_id the CPO gave you in the Sessions module.

PUT /ocpi/2.2.1/chargingprofiles/1a2b3c-session-9f8e HTTP/1.1
Host: cpo.example.com
# Auth scheme is the literal word "Token" — NOT "Bearer" — then the credentials
# token the CPO issued to THIS eMSP during the Credentials handshake.
Authorization: Token <token-the-CPO-issued-to-this-eMSP>
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

{
  // Where the CPO POSTs the ASYNC result once the charger has (or hasn't)
  // applied the profile. This must be an endpoint the eMSP exposes and can
  // match back to this session. The sync response below is only an ACK.
  "response_url": "https://emsp.example.com/ocpi/2.2.1/chargingprofiles/results/1a2b3c-session-9f8e",
  "charging_profile": {
    "start_date_time": "2026-07-04T22:00:00Z",  // RFC 3339, UTC ("Z"). Optional.
    "duration": 28800,                 // seconds the profile applies (8h). Optional.
    // THE unit gotcha: every "limit" below is interpreted in THIS unit. "W" =
    // watts, "A" = amps. A limit of 16 means 16 W with unit "W" but 16 A
    // (~11 kW at 400 V 3-phase) with unit "A". Send the wrong unit and you
    // either throttle the charger to a trickle or flood it — pick one and be
    // sure it matches what your limits actually mean.
    "charging_rate_unit": "W",         // "W" | "A"
    "min_charging_rate": 1400,         // optional lower bound, same unit as above
    "charging_profile_period": [       // schedule as {start_period, limit} pairs
      { "start_period": 0,     "limit": 7000  },  // start_period = SECONDS from
      { "start_period": 14400, "limit": 11000 },  // start_date_time, NOT a clock
      { "start_period": 25200, "limit": 22000 }   // time. 0 = at the start.
    ]
  }
}
# ── CPO ──▶ eMSP (the SYNC response — only an ACK) ──────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json
X-Request-ID: 12345
X-Correlation-ID: 67890

{
  // data is a ChargingProfileResponse. result is ONLY whether the CPO accepted
  // the request for processing — NOT whether the charger applied the profile.
  "data": {
    "result": "ACCEPTED",             // ACCEPTED | NOT_SUPPORTED | REJECTED | TOO_OFTEN | UNKNOWN_SESSION
    "timeout": 30                      // seconds to wait for the async result below
  },
  // 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-04T21:59:31Z"
}
# ── CPO ──▶ eMSP (the ASYNC result, seconds later) ─────────────────────────
# This is the REAL outcome. The CPO POSTs it to the response_url from the PUT,
# after it has translated the profile into an OCPP SetChargingProfile and heard
# back from the charge point. Miss this and you never learn if the profile
# actually took effect — the async trap that catches Commands users too.

POST /ocpi/2.2.1/chargingprofiles/results/1a2b3c-session-9f8e HTTP/1.1
Host: emsp.example.com
# Opposite direction, opposite token: the one the eMSP issued to the CPO.
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json

{
  // data is a ChargingProfileResult.
  "data": {
    "result": "ACCEPTED"              // ACCEPTED | REJECTED | UNKNOWN
  },
  "status_code": 1000,
  "status_message": "Success",
  "timestamp": "2026-07-04T21:59:36Z"
}
# ── eMSP ──▶ CPO (the eMSP's ACK of the async POST) ────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {},                         // nothing to return; the eMSP just ACKs
  "status_code": 1000,                // again: check this, not just the HTTP 200
  "status_message": "Success",
  "timestamp": "2026-07-04T21:59:36Z"
}

2. The eMSP reads the active profile (GET from the CPO)

# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# Same async pattern: the GET carries its own response_url. The sync answer is
# just an ACCEPTED ACK; the actual active profile comes back to response_url.

GET /ocpi/2.2.1/chargingprofiles/1a2b3c-session-9f8e?duration=900&response_url=https%3A%2F%2Femsp.example.com%2Focpi%2F2.2.1%2Fchargingprofiles%2Fresults%2F1a2b3c-session-9f8e HTTP/1.1
Host: cpo.example.com
Authorization: Token <token-the-CPO-issued-to-this-eMSP>
# Query params:
#   duration     → how many seconds of the active profile you want reported
#   response_url → URL-encoded; where the async ActiveChargingProfileResult lands
# ── CPO ──▶ eMSP (the SYNC response — only an ACK) ──────────────────────────
HTTP/1.1 200 OK
Content-Type: application/json

{
  "data": {
    "result": "ACCEPTED",             // ACCEPTED | NOT_SUPPORTED | REJECTED | TOO_OFTEN | UNKNOWN_SESSION
    "timeout": 30
  },
  "status_code": 1000,
  "status_message": "Success",
  "timestamp": "2026-07-04T22:15:00Z"
}
# ── CPO ──▶ eMSP (the ASYNC result, seconds later) ─────────────────────────
# POSTed to the response_url. data is an ActiveChargingProfileResult carrying
# the profile the charger is actually running right now.

POST /ocpi/2.2.1/chargingprofiles/results/1a2b3c-session-9f8e HTTP/1.1
Host: emsp.example.com
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
Content-Type: application/json

{
  "data": {
    // data is an ActiveChargingProfileResult. result* is required; profile is
    // present only when result is ACCEPTED.
    "result": "ACCEPTED",             // required: ACCEPTED | REJECTED | UNKNOWN
    "profile": {                      // optional: an ActiveChargingProfile
      // required: when the charger CALCULATED this active profile (RFC 3339, UTC "Z")
      "start_date_time": "2026-07-04T22:00:00Z",
      "charging_profile": {           // required: the ChargingProfile now running
        // optional: absolute start of THIS profile. Absent → relative to charging start.
        "start_date_time": "2026-07-04T22:00:00Z",
        "duration": 28800,            // optional: seconds the profile applies (8h)
        "charging_rate_unit": "W",    // required: "W" | "A" — same unit rules on the way back
        "min_charging_rate": 1400,    // optional: lower bound, same unit as above
        "charging_profile_period": [  // optional: {start_period*, limit*} pairs, both required
          { "start_period": 0,     "limit": 7000  },  // start_period = SECONDS from
          { "start_period": 14400, "limit": 11000 },  // start_date_time, NOT a clock
          { "start_period": 25200, "limit": 22000 }   // time. 0 = at the start.
        ]
      }
    }
  },
  "status_code": 1000,
  "status_message": "Success",
  "timestamp": "2026-07-04T22:15:04Z"
}

To clear a profile, the eMSP sends DELETE /ocpi/2.2.1/chargingprofiles/{session_id}?response_url=…. It follows the identical two-step shape: a sync ACCEPTED ACK, then the real outcome POSTed to the response_url.

Where reality complicates things

A list of things that make ChargingProfiles harder to deploy than the spec suggests.

Charge-point smart-charging support is uneven. Many older charge points run OCPP 1.6 and don’t implement the smart-charging profile feature, or implement it incorrectly. Sending a ChargingProfile to a CPO whose hardware can’t honor it just produces a rejection.

OCPP-version mismatches. OCPP 2.0.1 has clean smart-charging primitives. OCPP 1.6 added smart-charging as an extension. Translation between the OCPI ChargingProfile and the OCPP version actually deployed at the charger is the CPO’s problem, but the abstraction leaks (some profiles are expressible in 2.0.1 but not 1.6, etc.).

Local energy management. Many sites have local energy management systems (DLM — dynamic load management) that already adjust charger output based on building load. A ChargingProfile request from an eMSP might be overridden by local logic. Who wins? Implementation-defined.

Timing precision. “Start at 22:00:00 UTC” is precise on paper. In practice the CPO’s network has latency, the charger’s clock may drift, and the actual ramp may happen plus or minus minutes. For pure billing purposes this is fine; for fast-acting grid services it’s a real problem.

User overrides. If a user wants to override the profile (e.g. “I need to leave in an hour, charge as fast as you can”), most charger UIs and apps don’t expose a way to do this cleanly. The eMSP would need to push an updated profile, which requires the eMSP’s app to be involved — and if the user is on the CPO’s drive-up payment, the eMSP isn’t in the loop at all.

Reservation interactions. If a user reserved the charger, was their profile set at reservation time or session start time? Both options have semantics. The spec gives flexibility; implementations vary.

Common implementation gotchas

A few things that catch real-world implementations.

The async trap and the W-vs-A ambiguity — the #1 mistake

The single most common ChargingProfiles bug is treating the synchronous response as the answer. It is not. Like the Commands module, ChargingProfiles is asynchronous: the ChargingProfileResponse you get back on the PUT (or GET, or DELETE) is only an acknowledgement that the CPO accepted the request for processing. The real outcome — whether the charger actually applied the profile — arrives later as a separate POST to the response_url you supplied:

# The sync ACK — do NOT treat this as "the profile is applied":
{ "data": { "result": "ACCEPTED", "timeout": 30 }, "status_code": 1000, "status_message": "Success", "timestamp": "..." }

# The async result, POSTed to your response_url seconds later — THIS is the outcome:
{ "data": { "result": "ACCEPTED" }, "status_code": 1000, "status_message": "Success", "timestamp": "..." }

Note the two different result value sets, and don’t mix them up: the sync ChargingProfileResponse.result is ACCEPTED | NOT_SUPPORTED | REJECTED | TOO_OFTEN | UNKNOWN_SESSION, while the async ChargingProfileResult.result is ACCEPTED | REJECTED | UNKNOWN. A sync ACCEPTED followed by an async REJECTED is normal — the request was well-formed but the charger couldn’t honor it. If you never stand up the response_url receiver, you will believe every profile succeeded when many silently didn’t.

The second half of the same mistake is the charging_rate_unit ambiguity. Every limit in the profile is interpreted in whatever charging_rate_unit you set — W (watts) or A (amps) — and nothing in the number itself tells you which. A limit of 16 means 16 watts under "W" (a trickle that effectively stops charging) but 16 amps under "A" (roughly 11 kW on a 400 V three-phase supply). Set the wrong unit and you either throttle the session to nothing or push the charger past what you intended. Pick the unit deliberately, use it consistently across every period and min_charging_rate, and confirm it matches what your limit numbers actually mean.

For the authoritative field-by-field definitions, see the ChargingProfiles module in the OCPI 2.2.1 specification.

session_id is the key — and it must be a live session

Every ChargingProfiles call is keyed by session_id, and it must be the same id the CPO issued in the Sessions module. Target a session the CPO doesn’t recognize (already ended, never existed, wrong id) and the sync response comes back UNKNOWN_SESSION. There is no location-wide “set a default for everything” call in this flow — the profile attaches to one running session.

response_url must be reachable and matchable

The response_url is not optional plumbing. It must be an endpoint your side actually exposes, reachable from the CPO, and constructed so you can match the incoming async POST back to the request that triggered it (embedding the session_id in the path, as the examples do, is the common approach). A response_url that 404s or that you can’t correlate leaves you with the ACK and nothing else.

start_period is seconds, not a clock time

Inside charging_profile_period, start_period is the number of seconds from start_date_time, not a wall-clock time. start_period: 14400 means “four hours after the profile starts,” not “at 14:40.” Treating it as a timestamp scrambles the whole schedule.

last_updated and timestamps are RFC 3339 UTC

As everywhere in OCPI, start_date_time, timestamp, and any last_updated are RFC 3339 in UTC with a trailing Z. Send a local-time or offset value and the schedule drifts by your timezone.

A sync ACCEPTED can still be overridden on-site

Even a fully successful round trip doesn’t guarantee the charger holds your profile. Local dynamic load management (DLM) may override an eMSP profile, and the spec does not mandate who wins — the resolution is implementation-defined. Treat a delivered profile as a request the site may adjust, not a guarantee.

A realistic view of adoption

A few honest observations about where ChargingProfiles is in 2026.

  • Most production CPOs do not support it. They support Locations, Sessions, CDRs, Tokens, Commands — the core modules. ChargingProfiles is on roadmaps.
  • The eMSPs pushing for it are the ones running demand-response pilots with utilities, often in jurisdictions with active grid-flexibility programs (California, Texas/ERCOT, Ontario, the UK, Germany, and the Netherlands).
  • Within single-operator vertically-integrated stacks (eMSP and CPO owned by the same parent), smart charging works fine — but it’s done via private APIs, not OCPI. The interest in OCPI ChargingProfiles is from operators who want this across organizational boundaries.
  • V2G via OCPI ChargingProfiles is theoretical today. The module can carry negative limits, but the hardware, the vehicle support (ISO 15118-20), and the regulatory frameworks are all still maturing.

This is not a criticism of the module — it’s a healthy state for a new optional feature. The standard is in place; adoption follows when commercial pressure justifies the engineering investment.

When to implement ChargingProfiles

If you’re a CPO:

  • Skip it for now unless you have a partner specifically asking for it or you’re operating in a jurisdiction where grid services are paying real money.
  • When you do implement it, do it after your underlying OCPP smart-charging story is solid. There’s no point in receiving OCPI ChargingProfiles you can’t translate to the charger.
  • Validate against the hardware mix. If 60% of your fleet is OCPP 1.6 without smart-charging extensions, your real coverage is 40%.

If you’re an eMSP:

  • Implement it when you have a business case. A demand-response program with a utility, a TOU-optimization product for users, or a V2G pilot.
  • Be prepared for low coverage. Even with full OCPI ChargingProfiles implementation, the number of CPO-served sessions where it actually works is small. Manage expectations.
  • Build the user-facing logic first. Knowing how to ask for a profile is easy. Knowing what profile to ask for, given a user’s car/destination/cost preferences/grid signals, is where the real product value is.

The relationship to V2G

A quick note on V2G specifically because it gets asked about often.

ChargingProfiles in OCPI 2.2 can carry negative limits, so the roaming layer can, in principle, describe discharge. On the CPO-to-charger side, native bidirectional charging is an OCPP 2.1 capability (2.1 added a dedicated bidirectional/V2G functional block; OCPP 2.0.1 has no built-in bidirectional blocks). ISO 15118-20 on the vehicle side can negotiate bidirectional flow. So the pieces of a standards stack are coming together.

What’s missing is:

  • Vehicles in the field that support 15118-20 bidirectional (most don’t yet).
  • Charger hardware that supports bidirectional flow (most don’t).
  • Tariffs and grid-service compensation frameworks that make V2G economically attractive to users (still maturing).

By the time the rest of the stack catches up, OCPI ChargingProfiles will be the standard way the eMSP coordinates the V2G action. For now, the production deployments of V2G are mostly bilateral integrations rather than ChargingProfiles-mediated roaming.

The honest summary

ChargingProfiles is a well-designed module solving a real problem that hasn’t yet become a widespread commercial reality. The spec is sound, the data model is sensible, and the abstractions match the underlying OCPP capabilities. The reasons most teams haven’t adopted it are not technical — they’re commercial. As demand-response programs, V2G pilots, and time-of-use optimization products mature, ChargingProfiles will become more relevant. Until then, it’s worth understanding but not worth rushing to implement unless you have a specific use case in hand.

Quick check

Q1. Which two modules did OCPI 2.2 newly introduce?
Q2. When a CPO receives an OCPI ChargingProfile, how does it usually reach the physical charge point?
Q3. What is the main reason ChargingProfiles adoption is low in 2026?
Q4. Why is real V2G via OCPI ChargingProfiles still largely theoretical?
Q5. A ChargingProfile arrives at a site with a local dynamic load management (DLM) system. What does the article say about conflicts?

Frequently asked questions

Does ChargingProfiles work on AC chargers?

In theory, yes — the protocol is connector-agnostic. In practice, smart-charging support on Level 2 AC chargers via roaming is rare today. The combination of OCPP smart-charging on the hardware, OCPI ChargingProfiles on the roaming side, and an eMSP willing to invest in the orchestration is uncommon outside of pilot programs.

Can ChargingProfiles do vehicle-to-grid (V2G)?

The OCPI module can express negative power (charge discharge into the grid) in principle, but real V2G via roaming is dependent on ISO 15118-20 support on the vehicle, bidirectional hardware on the charger, and CPO-side support. ChargingProfiles is the roaming-layer plumbing for V2G but not the whole story.

Is ChargingProfiles required to implement OCPI 2.2?

No. It is optional and most CPOs and eMSPs do not implement it yet. You can be fully compliant with OCPI 2.2 without it.

What happens if the CPO rejects a ChargingProfile request?

The CPO responds with a status indicating rejection (typically because the charger does not support smart charging, the session is in an incompatible state, or local constraints conflict). The eMSP must handle the rejection gracefully. The session continues at whatever the default rate is.

Found this useful? Share it.