Tariffs & coupons

This page describes how the EMSP prices charging with its own back-office tariffs, how those tariffs are assigned to connectors, users and organizations, and how discount coupons reduce the final price.

Two kinds of tariff

All tariffs are stored together, but they come from two sources:

KindCreated byManaged through this API
EMSP (back-office) tariffYou, through POST /backoffice/tariffsYes. The /backoffice/tariffs endpoints list and change only this kind.
CPO tariffThe charge point operator, pushed over OCPI or HubjectNo. It is attached to the operator's connectors by the roaming sync.

An EMSP tariff is your own retail price. It replaces the operator's price for the users, organizations and connectors that it is assigned to. When no EMSP tariff applies, the session is priced from the CPO side: the CPO tariff or the amounts in the CPO's CDR.

How a session's tariff is resolved

The resolution runs when a session is created (the tariff is stored on the session as a snapshot) and again when the CDR is priced. The input is the connector, the user, the user's organization (if any), and whether the session is paid by the organization.

With assignment resolution enabled, the platform checks assignment scopes in a fixed order. The first scope that has a tariff for the connector wins:

ScopeMatches when the tariff is assigned to…
USER_CONNECTORthis user and this connector
ORGANIZATION_CONNECTORthe user's organization and this connector
USERthis user, and to no connectors (applies on every connector)
ORGANIZATIONthe user's organization, and to no connectors
CONNECTORthis connector, and to no users or organizations (applies to every user)

The scope order depends on the deployment's resolution profile:

ProfileSituationOrder
DEFAULTUser belongs to an organizationUSER_CONNECTOR → ORGANIZATION_CONNECTOR → USER → ORGANIZATION → CONNECTOR
DEFAULTUser has no organizationUSER_CONNECTOR → USER → CONNECTOR
CORPORATEPrivate (non-corporate) paymentUSER_CONNECTOR → USER → CONNECTOR
CORPORATECorporate paymentORGANIZATION_CONNECTOR → ORGANIZATION → CONNECTOR

If two tariffs match within the same scope, the one with the lowest tariff ID wins. The assignment rules below prevent most of these overlaps.

flowchart LR
    S[Session or CDR<br/>connector + user + org] --> A{EMSP tariff found<br/>in assignment scopes?}
    A -- yes --> E[Price with EMSP tariff]
    A -- no --> C[Price from CPO side<br/>CPO tariff / CPO CDR amounts]
    E --> D[Apply user's coupon]
    C --> D
    D --> CDR[CDR total cost]

Tariff anatomy

A tariff has a name, a countryCode (two letters), a currency (three uppercase letters, ISO 4217), optional tags, and a non-empty list of elements. Each element holds one priceComponent and optional restrictions.

{
  "name": "Sample DC retail",
  "countryCode": "PL",
  "currency": "PLN",
  "tags": ["dc", "retail"],
  "elements": [
    {
      "priceComponent": { "dimension": "FLAT", "stepSize": 1, "price": 1.00, "vatRate": 23 }
    },
    {
      "priceComponent": { "dimension": "ENERGY", "stepSize": 100, "price": 2.10, "vatRate": 23 }
    },
    {
      "priceComponent": { "dimension": "PARKING_TIME", "stepSize": 60, "price": 12.00 },
      "restrictions": { "startTime": "08:00", "endTime": "20:00", "daysOfWeek": ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY"] }
    }
  ]
}

Price components

dimensionprice is perstepSize unit
ENERGYkWhWh
TIMEhour of chargingseconds
PARKING_TIMEhourseconds
FLATsessionnot used
SESSION_TIMEhourseconds
  • The billed volume is rounded up to whole steps. For example, ENERGY with stepSize: 1000 bills whole kWh.
  • price is the amount before VAT. vatRate is a percentage. If you omit vatRate, the platform default VAT applies.

Restrictions

All restriction fields are optional. An element applies only while all of its restrictions hold.

FieldFormat
startTime / endTimeHH:MM
startDate / endDatedate, YYYY-MM-DD
daysOfWeekMONDAY … SUNDAY
minEnergyKwh / maxEnergyKwhkWh
minDurationSec / maxDurationSecseconds

Updates and deletion

  • PUT /backoffice/tariffs/{id} replaces the whole tariff. Send every element. To keep an existing element, price component or restriction, include its id.
  • DELETE /backoffice/tariffs/{id} removes the tariff from every connector that holds it directly, then deletes the tariff. The response is 200 with an empty body.

Listing

POST /backoffice/tariffs/all lists EMSP tariffs. Filters go in the optional JSON body and paging goes in the query string:

ParameterWhereNotes
pagequery1-based, default 1
sizequerydefault 10, minimum 1, no maximum
namebodycase-insensitive substring
currenciesbodylist of currency codes
countryCodesbodylist of [A-Z]{2} codes
createdAtRange, updatedAtRangebody{ "from": <offset date-time>, "to": <offset date-time> }

Results are sorted by updatedAt, newest first. There is no sort parameter. The response uses the standard page envelope. GET /backoffice/tariffs/{id} also returns the counts numberOfAssignedUsers, numberOfAssignedOrganizations and numberOfAssignedConnectors.

Assigning tariffs

A tariff is assigned to three kinds of asset: connectors, users and organizations. Which kinds a tariff holds decides its scope:

Tariff assigned toResulting scope
Connectors onlyCONNECTOR: everyone pays this tariff on those connectors
Users onlyUSER: those users pay it everywhere
Organizations onlyORGANIZATION: members pay it everywhere
Users and connectorsUSER_CONNECTOR: those users pay it on those connectors
Organizations and connectorsORGANIZATION_CONNECTOR

Asynchronous jobs

The assign and unassign calls do not change data directly. Each call selects assets with a filter, stores a job, and returns 202 Accepted with the job.

stateDiagram-v2
    [*] --> IN_PROGRESS: assign / unassign accepted (202)
    IN_PROGRESS --> PROCESSING: picked up by scheduler
    PROCESSING --> SUCCEEDED: no failures
    PROCESSING --> PARTIALLY_SUCCEEDED: some assets failed
    PROCESSING --> FAILED: nothing succeeded, or job error (failureReason)
StatusMeaning
IN_PROGRESSQueued. The job is not started yet.
PROCESSINGThe scheduler is running the job.
SUCCEEDEDEvery selected asset was processed.
PARTIALLY_SUCCEEDEDSome assets succeeded and some failed. See the failure report.
FAILEDNo asset succeeded, or the job failed as a whole (failureReason).

The scheduler runs every 5 seconds by default and takes the oldest queued job first. Assets are processed in batches of 1,000. The operation field of a job is one of ASSIGN_CONNECTORS, UNASSIGN_CONNECTORS, ASSIGN_USERS, UNASSIGN_USERS, ASSIGN_ORGANIZATIONS or UNASSIGN_ORGANIZATIONS.

Filters

Each request body has the shape { "match": {…}, "except": {…} }. except always takes priority over match.

Warning: If you send no body, or an empty match, the filter selects all records. For example, POST /tariffs/{id}/connectors/assign with no body targets every connector in the network.

AssetMain predicate fields (see Swagger for the full list)
ConnectorchargingPoolsIds, chargingPointsIds, connectorsIds and their cpo*Ids variants, chargingPointsStatuses, connectorsStandards, powerTypes, formats, powerMin/powerMax (W), amperageMin/amperageMax, voltageMin/voltageMax, countries, roamingTypes, cpoNameLike, evseIdLike, addressLike, chargingPoolNameLike, updatedAtFrom/updatedAtTo, assignment
UseremailLike, usersIds, usersStatuses (ACTIVE, BLOCKED, UNKNOWN), assignment
OrganizationorgNameLike, orgCodeLike, organizationsIds, assignment

ID sets accept at most 100 entries. assignment ({ "from", "to" }, UTC date-times) filters by assignment time and applies only to assets that are already assigned to the tariff.

The list endpoints (POST /tariffs/{id}/connectors, …/connectors/available, and the same pairs for users and organizations) take the same filter plus page (1-based, default 1) and size (default 10, maximum 100) in the JSON body.

Conflicts

Assets that are already assigned to the same tariff are skipped. An asset fails, and is listed in the failure report, if assigning it would create a second tariff in the same scope. Examples:

Error codeCause
CONNECTOR_TARIFF_WITHOUT_USERS_AND_ORGANIZATIONS_CONFLICTThe connector already has another connector-only tariff.
CONNECTOR_TARIFF_WITH_USERS_CONFLICT / CONNECTOR_TARIFF_WITH_ORGANIZATIONS_CONFLICTThe connector already has another tariff for the same users or organizations.
USER_TARIFF_WITHOUT_CONNECTORS_CONFLICT / USER_TARIFF_WITH_CONNECTORS_CONFLICTThe user already has another user-only tariff, or another tariff on the same connectors.
ORGANIZATION_TARIFF_WITHOUT_CONNECTORS_CONFLICT / ORGANIZATION_TARIFF_WITH_CONNECTORS_CONFLICTThe same conflicts, for organizations.
INTERNAL_ERRORUnexpected error.

GET /tariffs/{tariffId}/assignments/{jobId}/failures returns the failures as a page. Each failure has type CONNECTOR, USER or ORGANIZATION, a conflictingTariff (id, name) and an errorMessage (code, message). This endpoint takes page, size (maximum 100) and sort (default createdAt,asc) as query parameters. …/failures/export streams the same data as CSV, with the columns Asset, Conflicting tariff and Failure reason.

Discount coupons

A coupon is identified by its code. One user redeems it, and it then reduces the price of that user's sessions.

Coupon types

Two enums classify a coupon:

type (discount kind)Effect on a session's price
PERCENTAGE (default)Takes discountPercentage (default 100) off the share of the session's energy that is still inside the coupon's energy limit. The rest of the energy is billed at full price.
PRICETakes the fixed discountValue off one session, but never below zero. After the first discounted CDR, the coupon is applied and its status becomes Used.
PRICE_MULTI_USESpends remainingDiscountValue, which starts at discountValue, across sessions until the balance reaches zero.
couponType (energy limit window)Energy limit
NORMAL (default)energyLimit (kWh) is a lifetime cap. If you omit it, energy is unlimited.
WEEKLYThe energyLimit cap resets every Monday 00:00.
MONTHLYThe energyLimit cap resets on the 1st day of each month.

Energy limits affect only PERCENTAGE coupons.

Validity

You must set expirationDate, durationInDays, or both:

  • With expirationDate only: the coupon can be redeemed until that time, and it stays usable until that time.
  • With durationInDays only: the coupon can be redeemed within durationInDays of its creation. It is usable for durationInDays after redemption.
  • With both: expirationDate sets both limits.

Optional allowedEmails limits which users can redeem the coupon. The email match is case-insensitive.

Lifecycle

stateDiagram-v2
    [*] --> READY_TO_USE: created / imported
    READY_TO_USE --> ACTIVATED: redeemed by a user
    READY_TO_USE --> EXPIRED: expirationDate passed
    ACTIVATED --> USED: energy limit reached, or PRICE/PRICE_MULTI_USE fully applied
Filter value (status query param)Value in responses (status field)Condition
READY_TO_USEReady to useNot redeemed and not expired
ACTIVATEDActivatedRedeemed, limit not reached, not applied
USEDUsedEnergy limit reached, or applied = true
EXPIREDExpiredNot redeemed and expirationDate has passed

The response status field holds the display label (for example Ready to use). The filter uses the enum name (for example READY_TO_USE). A redeemed coupon whose usage window has ended still shows Activated. Check validUntil to see when the window ends.

Redemption rules

POST /backoffice/coupons with { "userEmail", "coupon" } redeems a coupon on behalf of a registered user. The response is 204. The call fails if any of these is true:

  • The user already has an active coupon. A user can hold only one valid coupon at a time. Response: 400, User … already has active coupon.
  • The coupon has expired (400). A coupon that is already redeemed is not found, so the call returns 404.
  • The user's email is not in the coupon's allowedEmails.
  • The user (404, Cannot find user with username: …) or the coupon (404) does not exist.

When the CDR is priced, the coupon that was valid at session start is applied. Its consumed energy or remaining value is updated, and the CDR records appliedCouponId. Coupons are skipped for free-of-charge (private station) CDRs. See Sessions & CDRs.

Creating coupons

  • One coupon: POST /backoffice/coupons/create with a JSON body. The response is 201 with an empty body.
  • CSV import: POST /backoffice/coupons/import, /import/weekly or /import/monthly as multipart/form-data. Send the file plus the coupon settings as form parameters (partnerName is required, and /weekly and /monthly also require energyLimit). The CSV has a header row, then two columns for each row: the allowed emails (one or more, comma-separated) and the coupon code. The import is all or nothing: one bad row rejects the whole file.
  • Delete: DELETE /backoffice/coupons/{id} returns 204. A redeemed coupon cannot be deleted (400).

Listing coupons

GET /backoffice/coupons takes these query parameters:

ParameterNotes
page1-based, default 1
sizedefault 10, no maximum
sortBycode, activationDate (the redemption time), expirationDate, value, createdAt (default)
sortDirectionASC or DESC (default)
Filterspartner, status, code, type, valueFrom/valueTo, activationDateFrom/activationDateTo, expirationDateFrom/expirationDateTo (ISO date-time, YYYY-MM-DDTHH:MM:SS), userEmail (the redeemer)

The items are in content, the totals are in totalElements/totalPages, and the page index is in number, which is 0-based. GET /backoffice/coupons/totals takes the same filters and returns couponsCount, totalDiscountedEnergy, numberOfActivatedCoupons and numberOfUsedCoupons. GET /backoffice/coupons/emails?code=… returns the standard envelope.

Examples

Create an EMSP tariff:

curl -X POST "https://<host>/backoffice/tariffs" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Sample AC retail",
        "countryCode": "PL",
        "currency": "PLN",
        "elements": [
          { "priceComponent": { "dimension": "ENERGY", "stepSize": 1000, "price": 1.80, "vatRate": 23 } }
        ]
      }'
{
  "id": "9a1b2c3d-0000-4000-8000-000000000001",
  "name": "Sample AC retail",
  "countryCode": "PL",
  "currency": "PLN",
  "elements": [
    {
      "id": "9a1b2c3d-0000-4000-8000-0000000000e1",
      "priceComponent": { "id": "9a1b2c3d-0000-4000-8000-0000000000c1", "dimension": "ENERGY", "stepSize": 1000, "price": 1.80, "vatRate": 23 },
      "restrictions": null
    }
  ],
  "createdAt": "2026-10-27T10:00:00Z",
  "updatedAt": "2026-10-27T10:00:00Z",
  "tags": []
}

Assign the tariff to all AC connectors of two pools, except one connector, then poll the job:

curl -X POST "https://<host>/backoffice/tariffs/9a1b2c3d-0000-4000-8000-000000000001/connectors/assign" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "match":  { "chargingPoolsIds": ["pool-0001", "pool-0002"], "powerTypes": ["AC_3_PHASE"] },
        "except": { "connectorsIds": ["connector-0042"] }
      }'

curl "https://<host>/backoffice/tariffs/9a1b2c3d-0000-4000-8000-000000000001/assignments/3e160425-0000-4000-8000-0000000000aa" \
  -H "Authorization: $API_KEY"
{
  "id": "3e160425-0000-4000-8000-0000000000aa",
  "status": "PARTIALLY_SUCCEEDED",
  "operation": "ASSIGN_CONNECTORS",
  "numberOfCorrectlyProcessedRecords": 46,
  "numberOfIncorrectlyProcessedRecords": 2,
  "failureReason": null,
  "createdAt": "2026-10-27T10:05:00Z",
  "updatedAt": "2026-10-27T10:05:07Z"
}

Create a weekly percentage coupon and redeem it for a user:

curl -X POST "https://<host>/backoffice/coupons/create" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "code": "SAMPLE-WEEKLY-50",
        "partnerName": "Sample Partner",
        "type": "PERCENTAGE",
        "discountPercentage": 50,
        "couponType": "WEEKLY",
        "energyLimit": 40,
        "durationInDays": 90
      }'

curl -X POST "https://<host>/backoffice/coupons" \
  -H "Authorization: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "userEmail": "[email protected]", "coupon": "SAMPLE-WEEKLY-50" }'

List activated coupons of one partner:

curl "https://<host>/backoffice/coupons?partner=Sample%20Partner&status=ACTIVATED&sortBy=activationDate&sortDirection=DESC&page=1&size=20" \
  -H "Authorization: $API_KEY"
{
  "content": [
    {
      "id": "c0ffee00-0000-4000-8000-000000000001",
      "code": "SAMPLE-WEEKLY-50",
      "partner": "Sample Partner",
      "type": "PERCENTAGE",
      "couponType": "WEEKLY",
      "discountPercentage": 50,
      "status": "Activated",
      "redeemedAt": "2026-10-20T08:00:00",
      "validUntil": "2027-01-18T08:00:00",
      "currentWeekUsage": 12.5,
      "currentWeekLimit": 40,
      "applied": false
    }
  ],
  "totalElements": 1,
  "totalPages": 1,
  "number": 0,
  "size": 20
}

Related


Did this page help you?