---
updatedAt: 2026-10-28T12:17:16.000Z
agentTools:
  projectIndex: https://developers.solidstudio.io/llms.txt
---

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

```mermaid
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`.

```json
{
  "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, `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.

| 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 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:

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

```mermaid
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/assign` with 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 `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

```mermaid
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 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](sessions-cdrs.md).

### 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:

| 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:

```bash
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 } }
        ]
      }'
```

```json
{
  "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:

```bash
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"
```

```json
{
  "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:

```bash
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": "driver@example.com", "coupon": "SAMPLE-WEEKLY-50" }'
```

List activated coupons of one partner:

```bash
curl "https://<host>/backoffice/coupons?partner=Sample%20Partner&status=ACTIVATED&sortBy=activationDate&sortDirection=DESC&page=1&size=20" \
  -H "Authorization: $API_KEY"
```

```json
{
  "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

* [Getting started](getting-started.md)
* [Authorization](authorization.md)
* [Pagination, filtering and errors](pagination-filtering-errors.md)
* [Sessions & CDRs](sessions-cdrs.md)
* [Payments & settlement](payments-settlement.md)
* [Customers & tokens](customers-tokens.md)
* [Charging network](charging-network.md)
* [Bulk operations](bulk-operations.md)