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:
| Kind | Created by | Managed through this API |
|---|---|---|
| EMSP (back-office) tariff | You, through POST /backoffice/tariffs | Yes. The /backoffice/tariffs endpoints list and change only this kind. |
| CPO tariff | The charge point operator, pushed over OCPI or Hubject | No. 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:
| Scope | Matches when the tariff is assigned to… |
|---|---|
USER_CONNECTOR | this user and this connector |
ORGANIZATION_CONNECTOR | the user's organization and this connector |
USER | this user, and to no connectors (applies on every connector) |
ORGANIZATION | the user's organization, and to no connectors |
CONNECTOR | this connector, and to no users or organizations (applies to every user) |
The scope order depends on the deployment's resolution profile:
| Profile | Situation | Order |
|---|---|---|
DEFAULT | User belongs to an organization | USER_CONNECTOR → ORGANIZATION_CONNECTOR → USER → ORGANIZATION → CONNECTOR |
DEFAULT | User has no organization | USER_CONNECTOR → USER → CONNECTOR |
CORPORATE | Private (non-corporate) payment | USER_CONNECTOR → USER → CONNECTOR |
CORPORATE | Corporate payment | ORGANIZATION_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
dimension | price is per | stepSize unit |
|---|---|---|
ENERGY | kWh | Wh |
TIME | hour of charging | seconds |
PARKING_TIME | hour | seconds |
FLAT | session | not used |
SESSION_TIME | hour | seconds |
- The billed volume is rounded up to whole steps. For example,
ENERGYwithstepSize: 1000bills whole kWh. priceis the amount before VAT.vatRateis a percentage. If you omitvatRate, the platform default VAT applies.
Restrictions
All restriction fields are optional. An element applies only while all of its restrictions hold.
| Field | Format |
|---|---|
startTime / endTime | HH:MM |
startDate / endDate | date, YYYY-MM-DD |
daysOfWeek | MONDAY … SUNDAY |
minEnergyKwh / maxEnergyKwh | kWh |
minDurationSec / maxDurationSec | seconds |
Updates and deletion
PUT /backoffice/tariffs/{id}replaces the whole tariff. Send every element. To keep an existing element, price component or restriction, include itsid.DELETE /backoffice/tariffs/{id}removes the tariff from every connector that holds it directly, then deletes the tariff. The response is200with 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:
| Parameter | Where | Notes |
|---|---|---|
page | query | 1-based, default 1 |
size | query | default 10, minimum 1, no maximum |
name | body | case-insensitive substring |
currencies | body | list of currency codes |
countryCodes | body | list of [A-Z]{2} codes |
createdAtRange, updatedAtRange | body | { "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 to | Resulting scope |
|---|---|
| Connectors only | CONNECTOR: everyone pays this tariff on those connectors |
| Users only | USER: those users pay it everywhere |
| Organizations only | ORGANIZATION: members pay it everywhere |
| Users and connectors | USER_CONNECTOR: those users pay it on those connectors |
| Organizations and connectors | ORGANIZATION_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)
| Status | Meaning |
|---|---|
IN_PROGRESS | Queued. The job is not started yet. |
PROCESSING | The scheduler is running the job. |
SUCCEEDED | Every selected asset was processed. |
PARTIALLY_SUCCEEDED | Some assets succeeded and some failed. See the failure report. |
FAILED | No 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/assignwith no body targets every connector in the network.
| Asset | Main predicate fields (see Swagger for the full list) |
|---|---|
| Connector | chargingPoolsIds, 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 |
| User | emailLike, usersIds, usersStatuses (ACTIVE, BLOCKED, UNKNOWN), assignment |
| Organization | orgNameLike, 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 code | Cause |
|---|---|
CONNECTOR_TARIFF_WITHOUT_USERS_AND_ORGANIZATIONS_CONFLICT | The connector already has another connector-only tariff. |
CONNECTOR_TARIFF_WITH_USERS_CONFLICT / CONNECTOR_TARIFF_WITH_ORGANIZATIONS_CONFLICT | The connector already has another tariff for the same users or organizations. |
USER_TARIFF_WITHOUT_CONNECTORS_CONFLICT / USER_TARIFF_WITH_CONNECTORS_CONFLICT | The user already has another user-only tariff, or another tariff on the same connectors. |
ORGANIZATION_TARIFF_WITHOUT_CONNECTORS_CONFLICT / ORGANIZATION_TARIFF_WITH_CONNECTORS_CONFLICT | The same conflicts, for organizations. |
INTERNAL_ERROR | Unexpected 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. |
PRICE | Takes 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_USE | Spends 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. |
WEEKLY | The energyLimit cap resets every Monday 00:00. |
MONTHLY | The 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
expirationDateonly: the coupon can be redeemed until that time, and it stays usable until that time. - With
durationInDaysonly: the coupon can be redeemed withindurationInDaysof its creation. It is usable fordurationInDaysafter redemption. - With both:
expirationDatesets 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_USE | Ready to use | Not redeemed and not expired |
ACTIVATED | Activated | Redeemed, limit not reached, not applied |
USED | Used | Energy limit reached, or applied = true |
EXPIRED | Expired | Not 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 returns404. - 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/createwith a JSON body. The response is201with an empty body. - CSV import:
POST /backoffice/coupons/import,/import/weeklyor/import/monthlyasmultipart/form-data. Send thefileplus the coupon settings as form parameters (partnerNameis required, and/weeklyand/monthlyalso requireenergyLimit). 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}returns204. A redeemed coupon cannot be deleted (400).
Listing coupons
GET /backoffice/coupons takes these query parameters:
| Parameter | Notes |
|---|---|
page | 1-based, default 1 |
size | default 10, no maximum |
sortBy | code, activationDate (the redemption time), expirationDate, value, createdAt (default) |
sortDirection | ASC or DESC (default) |
| Filters | partner, 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
Updated 14 days ago