OCPI Tariffs Module Explained: Fields, JSON, Examples

How the OCPI Tariffs module models EV charging prices — per-kWh, per-minute, time-of-use, idle fees — with annotated JSON, step_size gotchas, worked examples.

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.

This article walks through the model in depth.

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. Example: an element for energy (per-kWh) plus an element for idle time (per-minute) plus an element for a session fee (flat) — all applied during one session.

Price component types

Inside each tariff_element, the price_components array has objects with:

  • type — ENERGY, TIME, FLAT, PARKING_TIME
  • price — the rate
  • vat — VAT/tax rate (jurisdictional)
  • step_size — granularity for billing (e.g., bill in 1 minute steps, or every 1 kWh)

Example components:

TypeMeansExample
ENERGYPer-kWh price€0.45/kWh
TIMEPer-minute (while charging)€0.10/minute
FLATOnce per session€1.00 session fee
PARKING_TIMEPer-minute while plugged in but not charging (idle)€0.20/minute idle fee

Most tariffs use ENERGY (with possibly a small TIME component as discouragement-against-hogging). A FLAT session fee is common at networks recovering their per-transaction infrastructure cost.

Restrictions — making prices time-aware

A tariff_restrictions object can constrain when a tariff_element applies:

  • start_time and end_time — time-of-day window (e.g., 22:00 to 06:00 for off-peak)
  • start_date and end_date — calendar window (e.g., a holiday week)
  • day_of_week — list of days (MONDAY, TUESDAY, …)
  • min_kwh — only applies above a kWh threshold
  • max_kwh — only applies below a kWh threshold
  • min_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 seconds
  • max_duration — session length is below X seconds
  • reservation — 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:

  1. Element A: ENERGY at €0.55/kWh, no restrictions (the default)
  2. Element B: ENERGY at €0.30/kWh, restricted to start_time: "22:00", end_time: "06:00" (off-peak)
  3. 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.

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 and 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 — a
# partial pricing update risks producing an internally 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 is 60x wrong — the single most common tariff mistake.
          "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) — 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 — follow the pages or you only ever 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 — read the real one back
#                          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 at 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 during 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:

  1. Stores the tariffs in their database, indexed by id
  2. References them from Locations data (via tariff_ids on Connector objects)
  3. Computes a predicted price for any given session before it starts (used for app display)
  4. Computes an actual price for any completed session (using the matching tariff and the actual session data)
  5. 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 just REGULAR

The type field in a Tariff supports several special cases:

  • REGULAR — the default
  • AD_HOC_PAYMENT — used for ad-hoc payments (credit card at the station) under AFIR compliance
  • PROFILE_CHEAP — cheapest tariff offered, often slower charging
  • PROFILE_FAST — fastest/premium tariff
  • PROFILE_GREEN — sustainability-focused tariff

Later OCPI releases lean harder on the AFIR-specific tariffs (e.g., AD_HOC_PAYMENT) to support transparent pricing for one-time customers at public stations; for how the Tariffs module has shifted across versions, see the OCPI version history and the official changelog.

Currency, taxes, and rounding

A few specific edges:

  • Currency is ISO 4217. Most tariffs use a single currency; the eMSP handles conversion if needed.
  • 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 #1 mistake

The single most common tariff bug is mixing up what step_size means. It is the increment the CPO rounds up to when billing that component — but its unit depends on the component type:

  • For ENERGY, step_size is in Wh (watt-hours). step_size: 1000 bills in whole-kWh (1000 Wh) increments.
  • For TIME and PARKING_TIME, step_size is in seconds. step_size: 60 bills in whole-minute increments.

Treat a TIME step_size as minutes (or an ENERGY one as kWh) and the bill comes out 60x — 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
}

Related trap: a Tariff.id is unique only within the publishing CPO. Reference it globally as country_code + party_id + id (e.g. NL + TNM + TARIFF_OFFPEAK_1), never the bare id — two CPOs can each ship a TARIFF1.

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 within the publishing CPO. To reference it globally, you need country_code + party_id + id. Implementations that confuse these create bugs.

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 (% renewable, suppliers, carbon footprint). It’s optional but increasingly important for ESG reporting. eMSPs that surface energy mix in their app create differentiated UX.

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

Three things to take from this

  1. Tariffs are structured pricing, not free-form. Per-kWh, per-time, flat, parking — these are the four price component types. Combinations cover almost every pricing model.

  2. Restrictions make tariffs time-aware. Off-peak rates, weekend pricing, idle fees after an hour — all expressed via restrictions on tariff_elements.

  3. PUT not PATCH for updates. Tariffs are tightly structured. Partial updates risk pricing inconsistencies, so OCPI replaces the whole object.

The OCPI Tariffs module is one of the trickier parts of the spec to implement well. Once it’s right, pricing changes become a data operation rather than an engineering one.

Quick check

Q1. In OCPI Tariffs, who owns the data?

Frequently asked questions

What does the OCPI Tariffs module do?

It defines how CPOs publish pricing information to eMSPs — including per-kWh rates, per-minute rates, flat session fees, idle fees, and time-of-use variations. eMSPs use this to show drivers accurate pricing before they charge.

Are tariffs the same across all eMSPs at a single CPO's station?

Not necessarily. A CPO can publish different tariffs to different eMSP partners based on commercial agreements. The Tariffs module supports per-party pricing.

Found this useful? Share it.