ChargingProfiles is one of the two 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: capping maximum power, scheduling charging across a time window, or, where the hardware allows, requesting bidirectional flow.
It is also optional, and that single fact shapes almost everything else about working with it. This article covers what the module defines, a full worked round trip on the wire, and the gotchas that turn a clean-looking spec into a support ticket.
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 those two roles are unfamiliar, the OCPI primer covers the actors and the four-corner model. Before OCPI 2.2 there was no standard slot for this in roaming. It still happened, but through bilateral integrations or inside a single operator’s own stack.
ChargingProfiles is the standardization layer.
What ChargingProfiles actually defines
The module has three main operations:
- PUT a charging profile for a specific session.
- GET the active charging profile for a session.
- DELETE (clear) a charging profile.
Plus an async notification flow for the CPO to tell the eMSP when a profile takes effect, is updated, or is rejected.
The ChargingProfile object mirrors OCPP’s smart-charging structure, which makes sense: the CPO is ultimately translating it into an OCPP SetChargingProfile message for the actual charge point. (The OCPI vs OCPP vs ISO 15118 breakdown covers where each protocol sits in the stack, and the OCPP smart charging deep dive covers the charger-side half of this handoff.) The key fields:
- start_date_time: when the profile takes effect.
- duration: how long it applies.
- charging_rate_unit:
W(watts) orA(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, for example “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 22:00 UTC, hold the session to 7kW for four hours, then 11kW for three hours, then 22kW for the final hour.
Note what those numbers are. They are ceilings, not setpoints. The charge point delivers the lower of the limit and whatever the vehicle is asking for, so a car that only wants 3kW at 02:00 still draws 3kW. A profile moves load away from the hours you want to avoid; it cannot push energy into a battery that isn’t asking for it.
The session-specific flow
A typical interaction:
- User plugs in at a CPO-operated charger.
- CPO sends Session START to the user’s eMSP (via the OCPI Sessions module).
- eMSP recognizes the driver as one of its own users and looks up their preferences plus current grid conditions.
- eMSP PUTs a ChargingProfile for that session ID to the CPO.
- CPO translates that into an OCPP SetChargingProfile message to the actual charge point.
- Charge point applies the profile. Charging rate now follows the schedule.
- eMSP can update the profile mid-session if conditions change (price spike, user override).
- 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
That is the happy path. Everything interesting happens on either side of it.
A complete worked example
The request snippet above is trimmed to the profile object. Here is the full
round trip in 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", followed by 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), 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-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 same async trap catches Commands module users.
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 coming 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
The things that make ChargingProfiles harder to deploy than the spec suggests.
Smart-charging support is a firmware property, not a model property. In OCPP 1.6, smart charging is an optional feature profile. A charge point can be fully 1.6 compliant and still reject SetChargingProfile outright. What settles it is what a given unit advertises in SupportedFeatureProfiles, not what the product page says about the model, and two units of the same model on different firmware can answer differently.
OCPP-version mismatches. OCPP 2.0.1 carries smart charging as a first-class part of the device model. OCPP 1.6 carries it as an optional profile alongside the core. Translating an OCPI ChargingProfile into whichever version the charger speaks is the CPO’s problem, but the abstraction leaks: a profile shape that expresses cleanly in 2.0.1 may have no exact 1.6 equivalent, and the CPO has to decide whether to approximate it or reject it.
Local energy management. A site running dynamic load management (coming soon) is already adjusting charger output against the building’s total load and its supply limit. When an eMSP profile arrives, two controllers want the same knob. OCPI does not mandate which one wins, so the answer is whatever the CPO built, and it may differ between two sites in the same portfolio.
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 land plus or minus some minutes. For billing purposes that is fine. For fast-acting grid services, where settlement runs on sub-minute intervals, it is a real problem.
User overrides. Suppose the driver wants out of the profile (“I need to leave in an hour, charge as fast as you can”). That override has to travel back through the eMSP, because the eMSP is the party holding the profile. It works when the driver is in the eMSP’s app. It does not work when the driver walked up and paid at the terminal, because in that session the eMSP has no relationship to override through.
Reservation interactions. If a user reserved the charger, was their profile set at reservation time or at session start? Both readings have defensible semantics. The spec leaves room for either, so implementations differ.
Common implementation gotchas
A few things that catch real-world implementations.
The async trap and the W-vs-A ambiguity
The bug that bites hardest here 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 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
a normal, expected sequence: 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 some of them 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 above do, is the common approach. A
response_url that 404s, or one you can’t correlate, leaves you holding 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 may override an eMSP profile, and the spec does not mandate who wins, so the resolution is implementation-defined. Treat a delivered profile as a request the site may adjust, not a guarantee.
What has to be true for a profile to land
Optional means optional, and that has consequences all the way down. Four separate things have to line up before a profile you send changes what a car draws, and each one is its own conversation.
The CPO has implemented the module. A CPO can be fully OCPI 2.2 compliant
without ChargingProfiles anywhere in its stack. If it hasn’t implemented it, your PUT
returns NOT_SUPPORTED and the spec offers no fallback path.
The module is enabled on your connection specifically. Modules are exchanged as an endpoint list during the Credentials handshake. A CPO that supports ChargingProfiles in general still has to expose that endpoint to you, which is a commercial decision as much as a technical one.
The charge point can honor it. The CPO’s translation to OCPP only helps if the unit at the far end implements smart charging, so a CPO’s real coverage is the subset of its ports whose firmware does.
No local controller outranks you. The site’s load management may cap or reshape whatever you asked for, and OCPI does not arbitrate.
Notice what none of those depend on: the OCPI version number. Implementing 2.2 or 2.2.1 buys you the vocabulary, not the coverage. Coverage is per-partner, and within a partner, often per-site.
There is also a structural reason the module exists at all. When the eMSP and the CPO are the same company, smart charging needs no standard: a private call between two of your own services is simpler and faster than a roaming protocol, and it can carry things OCPI has no field for. ChargingProfiles earns its keep exactly at the point where those two roles sit in different organizations, which is the same boundary the whole CPO/eMSP split exists to manage.
When to implement ChargingProfiles
If you’re a CPO:
- Skip it until a partner asks or a program pays. The right trigger is a named counterparty or a grid-services contract, not a roadmap slot.
- Do the OCPP side first. There is no point accepting OCPI ChargingProfiles you can’t translate to the charger. The receiving endpoint is the easy half.
- Count the ports that will actually honor a profile. Your coverage number is not your port count. It’s the subset whose firmware implements smart charging and whose site controller lets an external profile through. Measure it before you promise it to a partner.
If you’re an eMSP:
- Implement it when you have a business case. A demand-response contract, a time-of-use product for users, or a V2G pilot.
- Instrument the async leg separately. Log the sync ACK and the async result as two different events, and alert on profiles that never receive an async result at all. Without that split, the number you report is “requests sent,” which is not the same as “profiles applied.”
- Build the decision logic first. Knowing how to ask for a profile is easy. Knowing what profile to ask for, given the car, the departure time, the tariff, and the grid signal, is where the product value sits.
The relationship to V2G
ChargingProfiles can carry a negative limit, so the roaming layer can describe discharge as well as charge. That is one layer of a taller stack. The charger-side command set for native bidirectional operation is an OCPP 2.1 capability: 2.1, published January 2025, added a dedicated bidirectional functional block, where OCPP 2.0.1 has none. The vehicle-side negotiation is ISO 15118-20. The settlement is Tariffs and CDRs.
So the standards exist at every layer, and they compose. What ChargingProfiles specifically contributes is the piece that only matters across an organizational boundary: an eMSP asking a CPO it does not own for a discharge window, on a session it does not control. Inside one company that request is a function call, which is why the module is easy to skip until roaming is part of the plan. The bidirectional charging explainer (coming soon) covers what V2L, V2H, and V2G ask of the hardware itself.
The part worth remembering
With ChargingProfiles, the protocol is not the hard part. The object graph is small, the round trip is two hops in each direction, and a competent team can build both sides in a sprint.
What can’t be built in a sprint is the answer to the only question the module ever asks you: what limit should this session run at, right now? That needs a tariff feed, a departure-time estimate, a grid signal you have to source yourself, and a policy for what to do when they disagree. ChargingProfiles is a socket. The intelligence you plug into it is the product. A team that ships the integration first and works out the control logic afterwards ends up with a very correct way to send the wrong number.