The Tariffs module is where the money math happens in OCPI. It’s how a Charge Point Operator says to an eMSP: “this is what charging at our stations costs.” Get this right and pricing is transparent; get it wrong and drivers get billing surprises.
What a Tariff actually is
In OCPI, a Tariff is a structured pricing definition that the CPO publishes and the eMSP consumes (and ultimately uses to bill the customer). Each Tariff has an id and is referenced by Connectors in the Locations module, so a single station can have different tariffs for different connectors, and a single tariff can apply to many connectors across many locations. (If OCPI’s roles and interfaces are new to you, start with what OCPI is.)
The nesting matters. Knowing which level holds what determines how you model any real pricing scheme:
flowchart TB
T[Tariff: currency, type, dates]
T --> E1[tariff_element A: default]
T --> E2[tariff_element B: off-peak]
T --> E3[tariff_element C: idle after 1h]
E1 --> P1[ENERGY 0.55 EUR per kWh]
E2 --> P2[ENERGY 0.30 EUR per kWh]
E2 --> R2[restriction: 22:00 to 06:00]
E3 --> P3[PARKING_TIME 0.50 EUR per min]
E3 --> R3[restriction: min_duration 3600s]
style T fill:#dbeafe,stroke:#2563eb
style E1 fill:#fef3c7,stroke:#d97706
style E2 fill:#fef3c7,stroke:#d97706
style E3 fill:#fef3c7,stroke:#d97706
The basic Tariff object looks roughly like:
{
"country_code": "NL",
"party_id": "ABC",
"id": "TARIFF_OFFPEAK_1",
"currency": "EUR",
"type": "REGULAR", // or AD_HOC_PAYMENT, PROFILE_CHEAP, PROFILE_FAST, etc.
"elements": [...], // the price components (see below)
"start_date_time": "...",
"end_date_time": "...",
"energy_mix": {...}, // optional, sustainability info
"last_updated": "..."
}
The elements array is where the real complexity lives. Each element describes a price component (and optional restrictions on when it applies).
The element structure: where pricing actually lives
A tariff_element contains:
price_components: one or more price pieces (energy, time, flat, parking)restrictions: when this element applies (time of day, day of week, max kWh, min power, etc.)
A single Tariff can have multiple tariff_elements, each with different restrictions. The driver pays whichever apply during their session. Picture one element for energy (per-kWh), one for idle time (per-minute) and one for a session fee (flat). All three can land on a single session.
Price component types
Inside each tariff_element, the price_components array has objects with:
type: ENERGY, TIME, FLAT, PARKING_TIMEprice: the ratevat: VAT/tax rate (jurisdictional)step_size: granularity for billing (e.g., bill in 1 minute steps, or every 1 kWh)
Example components:
| Type | Means | Example |
|---|---|---|
ENERGY | Per-kWh price | €0.45/kWh |
TIME | Per-minute (while charging) | €0.10/minute |
FLAT | Once per session | €1.00 session fee |
PARKING_TIME | Per-minute while plugged in but not charging (idle) | €0.20/minute idle fee |
ENERGY is the component that tracks what the driver actually consumed, so it carries most pricing schemes on its own. A small TIME component alongside it exists to put a cost on occupying the connector, not on the electrons. A FLAT component recovers a fixed per-session cost (payment processing, for instance) that does not scale with kWh. For how each of the four behaves once a session is running, see OCPI tariff dimensions in depth.
Restrictions: making prices time-aware
A tariff_restrictions object can constrain when a tariff_element applies:
start_timeandend_time: time-of-day window (e.g., 22:00 to 06:00 for off-peak)start_dateandend_date: calendar window (e.g., a holiday week)day_of_week: list of days (MONDAY, TUESDAY, …)min_kwh: only applies above a kWh thresholdmax_kwh: only applies below a kWh thresholdmin_power: only applies above a kW threshold (e.g., DC fast pricing)max_power: only applies below a kW threshold (e.g., Level 2 pricing)min_duration: session has been ongoing for at least X secondsmax_duration: session length is below X secondsreservation: applies only if the EVSE was reserved
This is how time-of-use pricing, peak/off-peak, and weekend pricing get expressed.
Example: a CPO might publish a tariff with three elements:
- Element A: ENERGY at €0.55/kWh, no restrictions (the default)
- Element B: ENERGY at €0.30/kWh, restricted to
start_time: "22:00",end_time: "06:00"(off-peak) - Element C: PARKING_TIME at €0.50/minute, restricted to
min_duration: 3600(only kicks in after the session has been plugged in for 1 hour)
The driver’s actual price depends on when they’re charging and how long they sit plugged in.
The CPO’s publishing pattern
The CPO uses the Sender Interface to publish tariffs. The eMSP polls via GET or receives pushed updates via PUT to its Receiver Interface. Tariffs are one of the modules where both directions are in play, which is worth reading about on its own in OCPI pull vs push patterns (coming soon).
GET /ocpi/cpo/2.2.1/tariffs
Authorization: Token <eMSP credentials>
→ returns paginated list of tariffs
For real-time updates:
PUT /ocpi/emsp/2.2.1/tariffs/NL/ABC/TARIFF1
Authorization: Token <CPO credentials>
{
...new full Tariff object...
}
Tariffs in OCPI use PUT for updates rather than PATCH. The whole tariff object is replaced. This is intentional: tariffs are tightly structured, and partial updates risk creating inconsistent pricing. To remove a tariff, the CPO sends DELETE /ocpi/emsp/2.2.1/tariffs/{country_code}/{party_id}/{tariff_id}.
A complete worked example: CPO publishes, eMSP reads
The snippets above are trimmed for clarity. Here is the full round trip (the CPO
pushing a Tariff, then the eMSP reading it 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.)
1. The CPO pushes a Tariff (PUT to the eMSP)
# ── CPO ──▶ eMSP ────────────────────────────────────────────────────────────
# The CPO owns pricing, so it PUSHES the full object to the eMSP's Receiver
# Interface. PUT = "here is the complete, authoritative Tariff; replace whatever
# you hold for this id." Tariffs always use PUT (full replace), never PATCH,
# because a partial pricing update can produce an inconsistent tariff. To
# remove a tariff entirely, send DELETE to the same path.
PUT /ocpi/emsp/2.2.1/tariffs/NL/TNM/TARIFF_OFFPEAK_1 HTTP/1.1
Host: emsp.example.com
# Auth scheme is the literal word "Token", NOT "Bearer", then the credentials
# token the eMSP issued to THIS CPO during the Credentials handshake.
Authorization: Token <token-the-eMSP-issued-to-this-CPO>
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}/{tariff_id}. They MUST equal the
# country_code / party_id / id in the body. A mismatch is a common reject.
{
"country_code": "NL", // ISO 3166-1 alpha-2 of the CPO (2 chars)
"party_id": "TNM", // the CPO's party id (3 chars)
// id is unique only WITHIN this CPO. To reference a tariff globally you need
// country_code + party_id + id together, never the bare id.
"id": "TARIFF_OFFPEAK_1",
"currency": "EUR", // ISO 4217 currency code
"type": "REGULAR", // REGULAR | AD_HOC_PAYMENT | PROFILE_CHEAP | PROFILE_FAST | PROFILE_GREEN
"elements": [
{
// Element A: the default energy price, no restrictions → always applies.
"price_components": [
{
"type": "ENERGY", // ENERGY | TIME | FLAT | PARKING_TIME
"price": 0.55, // 0.55 EUR per kWh (excl. VAT)
"vat": 21.0, // VAT rate as a percentage
// step_size for ENERGY is in Wh. The CPO rounds consumed energy UP to
// the next multiple. 1000 = bill in whole-kWh (1000 Wh) increments.
"step_size": 1000
}
]
},
{
// Element B: cheaper energy overnight. Same price_component TYPE, but a
// restriction narrows WHEN it applies.
"price_components": [
{
"type": "ENERGY",
"price": 0.30, // 0.30 EUR per kWh off-peak
"vat": 21.0,
"step_size": 1000
}
],
"restrictions": {
"start_time": "22:00", // local time-of-day window, HH:MM
"end_time": "06:00"
}
},
{
// Element C: an idle fee that only kicks in after an hour plugged in.
"price_components": [
{
"type": "PARKING_TIME", // charged while plugged in but NOT charging
"price": 0.50, // 0.50 EUR per unit of step_size below
"vat": 21.0,
// step_size for TIME / PARKING_TIME is in SECONDS, not minutes. 60 =
// bill in whole-minute increments. Treat this as minutes and every
// idle bill comes out 60x wrong.
"step_size": 60
}
],
// TariffRestrictions: every field is OPTIONAL. Include only the ones that
// should narrow this element. This idle fee needs just min_duration. The
// other available restriction fields (all optional) and when you'd add them:
// start_time "HH:mm" local time-of-day window opens (see Element B)
// end_time "HH:mm" local time-of-day window closes
// start_date "YYYY-MM-DD" calendar window opens (e.g. a holiday week)
// end_date "YYYY-MM-DD" calendar window closes
// min_kwh number applies only at/above this delivered energy
// max_kwh number applies only below this delivered energy
// min_current number amps: applies only at/above this current
// max_current number amps: applies only below this current
// min_power number kW: applies only at/above this power (DC pricing)
// max_power number kW: applies only below this power (Level 2 pricing)
// max_duration int sec: applies only while session is shorter than this
// day_of_week [MONDAY..SUNDAY] restrict to specific weekdays
// reservation RESERVATION | RESERVATION_EXPIRES: only if EVSE was reserved
"restrictions": {
"min_duration": 3600 // required unit is SECONDS → only after 1 hour plugged in
}
}
],
// ── remaining Tariff fields ────────────────────────────────────────────────
// Everything from here down is OPTIONAL except last_updated (required). Shown
// fully populated so you can see the whole Tariff shape in one place.
"tariff_alt_text": [ // optional: human-readable price text, per language
{
"language": "en", // required: ISO 639-1 language code
"text": "0.55/kWh, 0.30/kWh from 22:00-06:00, 0.50/min idle after 1h" // required
},
{
"language": "nl",
"text": "0,55/kWh, 0,30/kWh van 22:00-06:00, 0,50/min stilstand na 1u"
}
],
"tariff_alt_url": "https://tnm.example.com/tariffs/offpeak-1", // optional: URL to full price info
"min_price": { // optional: min a session can ever cost (e.g. floor fee)
"excl_vat": 0.50, // required within Price
"incl_vat": 0.61 // optional within Price (0.50 * 1.21, VAT included)
},
"max_price": { // optional: price cap a session will never exceed
"excl_vat": 60.00, // required within Price
"incl_vat": 72.60 // optional within Price
},
"start_date_time": "2026-07-01T00:00:00Z", // optional: tariff not valid before this instant
"end_date_time": "2026-12-31T23:59:59Z", // optional: tariff not valid after this instant
"energy_mix": { // optional: sustainability info for the energy delivered
"is_green_energy": true, // required within EnergyMix
"energy_sources": [ // optional: breakdown by source (percentages sum to 100)
{ "source": "WIND", "percentage": 60.0 },
{ "source": "SOLAR", "percentage": 40.0 }
],
"environ_impact": [ // optional: e.g. carbon footprint per kWh
{ "category": "CARBON_DIOXIDE", "amount": 12.0 }
],
"supplier_name": "Green Volt Energy", // optional: name of the green-energy supplier
"energy_product_name": "100% Renewable" // optional: name of the energy product/plan
},
// 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
// downstream caches silently keep stale pricing. A classic, painful bug.
"last_updated": "2026-07-03T09:00:00Z" // required
}
# ── eMSP ──▶ CPO (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 eMSP reads Tariffs back (GET from the CPO)
# ── eMSP ──▶ CPO ────────────────────────────────────────────────────────────
# The eMSP reads from the CPO's Sender Interface. Typical flow: one bulk GET to
# seed the database (below), then rely on the CPO's pushed PUT/DELETE updates for
# changes. The list endpoint is paginated, so follow the pages or you only see
# the first slice.
GET /ocpi/cpo/2.2.1/tariffs?date_from=2026-07-01T00:00:00Z&offset=0&limit=50 HTTP/1.1
Host: cpo.example.com
# Opposite direction, opposite token: the one the CPO issued to the eMSP.
Authorization: Token <token-the-CPO-issued-to-this-eMSP>
# Query params:
# date_from / date_to → only tariffs changed in this window (delta sync)
# offset / limit → paging; the CPO may cap limit, so read the real one
# from the response headers below.
# ── CPO ──▶ eMSP (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 tariffs matching the filter, across ALL pages
# X-Limit → the page size the CPO actually applied (can be < your limit)
Link: <https://cpo.example.com/ocpi/cpo/2.2.1/tariffs?date_from=2026-07-01T00:00:00Z&offset=50&limit=50>; rel="next"
X-Total-Count: 84
X-Limit: 50
{
"data": [ // an ARRAY of Tariff objects (this page only)
{
// The eMSP reads back exactly what the CPO pushed: the full Tariff, with
// every element and price_component intact. required: country_code,
// party_id, id, currency, elements, last_updated. Everything else optional.
"country_code": "NL",
"party_id": "TNM",
"id": "TARIFF_OFFPEAK_1", // remember: unique only within CPO NL/TNM
"currency": "EUR",
"type": "REGULAR", // optional: REGULAR | AD_HOC_PAYMENT | PROFILE_CHEAP | PROFILE_FAST | PROFILE_GREEN
"elements": [
{
// Element A: default energy price, no restrictions → always applies.
"price_components": [
{
"type": "ENERGY", // required: ENERGY | TIME | FLAT | PARKING_TIME
"price": 0.55, // required: 0.55 EUR per kWh (excl. VAT)
"vat": 21.0, // optional: VAT rate as a percentage
"step_size": 1000 // required: ENERGY step is Wh → 1000 = whole-kWh increments
}
]
// no restrictions key → this element always applies
},
{
// Element B: cheaper energy overnight, narrowed by a time restriction.
"price_components": [
{
"type": "ENERGY",
"price": 0.30, // 0.30 EUR per kWh off-peak (excl. VAT)
"vat": 21.0,
"step_size": 1000
}
],
"restrictions": {
"start_time": "22:00", // optional: local time-of-day window opens, HH:mm
"end_time": "06:00" // optional: local time-of-day window closes, HH:mm
}
},
{
// Element C: idle fee that only kicks in after an hour plugged in.
"price_components": [
{
"type": "PARKING_TIME", // charged while plugged in but NOT charging
"price": 0.50, // 0.50 EUR per unit of step_size below (excl. VAT)
"vat": 21.0,
"step_size": 60 // TIME/PARKING_TIME step is SECONDS → 60 = whole-minute increments
}
],
"restrictions": {
"min_duration": 3600 // optional: SECONDS → only after 1 hour plugged in
}
}
],
"tariff_alt_text": [ // optional: human-readable price text, per language
{ "language": "en", "text": "0.55/kWh, 0.30/kWh from 22:00-06:00, 0.50/min idle after 1h" },
{ "language": "nl", "text": "0,55/kWh, 0,30/kWh van 22:00-06:00, 0,50/min stilstand na 1u" }
],
"tariff_alt_url": "https://tnm.example.com/tariffs/offpeak-1", // optional: URL to full price info
"min_price": { "excl_vat": 0.50, "incl_vat": 0.61 }, // optional: floor cost per session
"max_price": { "excl_vat": 60.00, "incl_vat": 72.60 }, // optional: price cap per session
"start_date_time": "2026-07-01T00:00:00Z", // optional: not valid before this instant
"end_date_time": "2026-12-31T23:59:59Z", // optional: not valid after this instant
"energy_mix": { // optional: sustainability info for the energy delivered
"is_green_energy": true, // required within EnergyMix
"energy_sources": [ // optional: breakdown by source (percentages sum to 100)
{ "source": "WIND", "percentage": 60.0 },
{ "source": "SOLAR", "percentage": 40.0 }
],
"environ_impact": [ // optional: e.g. carbon footprint per kWh
{ "category": "CARBON_DIOXIDE", "amount": 12.0 }
],
"supplier_name": "Green Volt Energy", // optional: green-energy supplier name
"energy_product_name": "100% Renewable" // optional: energy product/plan name
},
"last_updated": "2026-07-03T09:00:00Z" // required
}
// … up to `limit` Tariffs (X-Total-Count above says 84 total across all pages),
// 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"
}
Per-party tariffs: different prices for different eMSPs
A CPO can publish different tariffs to different eMSP partners. This supports:
- Wholesale agreements. A CPO charges Premium-eMSP a lower rate than Default-eMSP.
- White-label deployments. A partner runs their own brand with their own tariff.
- Promotional pricing. A CPO offers a special rate to a partner for the length of a campaign.
Implementation: the CPO maintains different tariff records and publishes them to the relevant eMSP via OCPI’s connection-specific credentials. The eMSP only sees tariffs that the CPO has shared with them.
How the eMSP uses tariffs
Once received, the eMSP:
- Stores the tariffs in their database, indexed by
id - References them from Locations data (via
tariff_idson Connector objects) - Computes a predicted price for any given session before it starts (used for app display)
- Computes an actual price for any completed session (using the matching tariff and the actual session data)
- Charges the driver’s payment method based on the actual price plus any eMSP markup
The eMSP doesn’t blindly trust the CDR (Charge Detail Record) from the CPO. They re-derive the price from the Tariff plus the data in the Sessions module and compare. This catches both errors and disputes.
Tariff types beyond REGULAR
The type field in a Tariff supports several special cases:
- REGULAR is the default.
- AD_HOC_PAYMENT covers ad-hoc payment at the station (a card tap rather than a roaming contract), which is the case AFIR price transparency rules (coming soon) are written around.
- PROFILE_CHEAP is the cheapest tariff on offer, often tied to slower power.
- PROFILE_FAST is the premium, high-power tariff.
- PROFILE_GREEN is the sustainability-oriented tariff.
The type field exists so an eMSP can tell one commercial offer from another without parsing the price components. That matters most for the ad-hoc case, where a one-time customer needs a price quoted before they tap a card rather than a contract rate. OCPI 2.3.0, published in February 2025, reworks this area further; what is new in 2.3.0 covers the changes, and the OCPI version history traces how the module got here. The official changelog is the source of record.
Currency, taxes, and rounding
A few specific edges:
- Currency is ISO 4217, and a Tariff carries exactly one. A CPO that needs to quote in two currencies publishes two Tariffs; converting to whatever the driver is billed in is the eMSP’s job.
- VAT is a percentage rate per price component. Useful in regions where VAT is included in the displayed price vs added separately.
- step_size controls billing granularity, and its unit depends on the component type. For ENERGY it is in Wh, so step_size=1000 means “bill in whole-kWh increments.” For TIME and PARKING_TIME it is in seconds, so step_size=60 means “bill in whole-minute increments.”
- Rounding at the session level is the CPO’s responsibility, but should match published behavior.
Common implementation gotchas
A few things that catch real-world implementations:
step_size units and billing granularity
The easiest field to get wrong is step_size, because its unit is not fixed. It
is the increment the CPO rounds up to when billing a component, and which unit
that increment is measured in depends on the component type:
- For
ENERGY,step_sizeis in Wh (watt-hours).step_size: 1000bills in whole-kWh (1000 Wh) increments. - For
TIMEandPARKING_TIME,step_sizeis in seconds.step_size: 60bills in whole-minute increments.
Treat a TIME step_size as minutes (or an ENERGY one as kWh) and the bill
comes out 60x wrong, or 1000x wrong. A correct block for a per-minute idle fee:
{
"type": "PARKING_TIME", // per-time component (plugged in, not charging)
"price": 0.50, // 0.50 per unit
"vat": 21.0,
"step_size": 60 // 60 SECONDS = round up to the next whole minute
}
For the exact field definitions and unit rules, see the OCPI 2.2.1 specification Tariffs module.
Tariffs change while a session is ongoing
A tariff can be updated during a session. OCPI’s general rule: the tariff in effect at the start of the session applies for the whole session, unless the spec’s behavior says otherwise. Implementation should snapshot the applicable tariff at session start.
A station referencing a missing tariff
If a Locations response references tariff_ids: ["TARIFF_X"] but the eMSP doesn’t have TARIFF_X yet, they need to fetch it. Implementations should handle this lazy-loading gracefully.
Tariff IDs aren’t globally unique
A Tariff.id is unique only within the publishing CPO. To reference one globally you need country_code + party_id + id, e.g. NL + TNM + TARIFF_OFFPEAK_1. Key your local store on the bare id and the day a second CPO ships its own TARIFF1 you will silently price sessions against the wrong operator’s rates.
Pricing displays vs actual charges
The pricing shown in the eMSP’s app before the session starts is a prediction (based on expected kWh and time). The actual charge depends on what really happened. The two won’t always match exactly. Apps should set driver expectations.
Energy mix is optional but powerful
The energy_mix field describes the sustainability of the energy delivered (share of renewables, supplier, carbon footprint). Nothing in the protocol depends on it, which is exactly why it tends to be dropped in parsing and then unavailable later when a fleet customer asks for per-session emissions figures. If you store the rest of the Tariff, store this too.
What this means for CPOs and eMSPs
For CPOs: the Tariffs module is your pricing strategy expressed as protocol. Time-of-use, peak/off-peak, idle fees and per-partner deals all live here. Get the schema right and you can iterate on pricing without engineering changes.
For eMSPs: Tariffs is your transparency and trust foundation. The driver sees prices in your app; if those prices don’t match the bill, you lose trust. Faithful tariff parsing and accurate predictions are the work.
Where the real ambiguity lives
A Tariff is not a price list. It is a set of instructions for computing a price,
and OCPI pins the data shape far more tightly than it pins the arithmetic. Two
faithful implementations can read the same Tariff and the same session data and
still land on different totals, because the JSON does not show you the decisions
underneath it: whether you round each step_size increment and then sum, or sum
and then round; whether a start_time/end_time window is evaluated once at
session start or re-evaluated as the clock crosses 22:00 mid-session; whether two
elements whose restrictions both match stack their components or whether the
first match wins.
Settle those questions in writing with each roaming partner before you exchange live pricing, because they surface as cent-level differences on real invoices, and a cent-level difference is enough to make a driver stop believing the number your app quoted. It is also the honest reason an eMSP recomputes the price instead of trusting the CDR. The point is rarely to catch a CPO in a lie. It is to catch the moment two correct implementations quietly diverged.