# OneGate REST API — v1

> Markdown reference for the OneGate REST API v1. **API version 1.1.0** (2026-10-13) — see §11.
> Interactive Postman documentation: <https://documenter.getpostman.com/view/54562521/2sBXqNkJKy>
>
> ⚠️ All examples below use **dummy data**. No real tokens, credentials, IDs or personal data are included — replace every placeholder with your own values.

---

## 1. Basics

| | |
|---|---|
| **Base URL** | `https://onegate.ro/api/vs1/` |
| **Protocol** | HTTPS only |
| **Format** | Every path **must** end in `.json` or `.xml`. A request without a supported extension returns `406 Not Acceptable`. |
| **Methods** | `GET` (read), `POST` (create), `PUT` (update), `DELETE` (remove). Each action allows a fixed set — a wrong verb returns `405 Method Not Allowed`. |
| **Request body** | `application/x-www-form-urlencoded` (or JSON). Use bracket notation for nested/associated data, e.g. `order_items[0][service_item_id]`. |
| **Auth** | JWT Bearer token in the `Authorization` header (see §2). |
| **Multi-tenancy** | Every request is automatically scoped to the account that owns the API user. You can never read or write another tenant's data. |
| **Server-owned fields** | Identifiers, statuses, amounts, exchange rates and the owning account are always set by OneGate. On writes, send only the fields documented for the endpoint; any other field is ignored. |

### Response envelope

All responses share the same envelope. Fields present depend on the action:

```json
{
  "status": 200,
  "message": "The entry has been saved.",
  "data": { },
  "total": 42,
  "errors": { }
}
```

| Field | When present |
|-------|--------------|
| `status` | Always. Mirrors the HTTP status code. |
| `data` | On success (single object, a list, or a paginated collection). |
| `message` | On writes (create/update/delete) and on most errors. |
| `total` | On paginated list endpoints (`index`) — total number of matching rows. |
| `errors` | On validation failures (`400`) — field → message map. |

To receive XML instead of JSON, swap the extension (`.xml`) — the same envelope is serialized as XML.

### Health check

`GET Hello/index.json` — unauthenticated. Returns API version, environment, the base URL, the current server time and the accepted authenticators/extensions. Useful to confirm connectivity before authenticating.

```bash
curl https://onegate.ro/api/vs1/Hello/index.json
```

---

## 2. Authentication

Authentication is a two-step flow: exchange your API credentials for a JWT, then send that JWT on every subsequent request.

### 2.1 Obtain a token

`POST Admins/login.json` — send your project API **email** and **password** (the credentials shown on `/admin/apis`). No token is required for this call.

```bash
curl -X POST https://onegate.ro/api/vs1/Admins/login.json \
  -d "email=api@example.com" \
  -d "password=s3cr3t-dummy-password"
```

```json
{
  "status": 200,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.DUMMY.PAYLOAD",
    "name": "OneGate",
    "version": "0.1.1",
    "baseUrl": "https://onegate.ro/",
    "environment": "production",
    "expire": {
      "timestamp": 1893456000,
      "datetime": "2030-01-01T00:00:00+00:00",
      "string": "+7 days"
    }
  }
}
```

The token is valid for **7 days**. Only active, non-deleted API users may log in; anything else returns `401`.

### 2.2 Use the token

Send the token as a Bearer header on every authenticated request:

```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.DUMMY.PAYLOAD
```

### 2.3 Current identity

`GET Admins/view.json` — returns the authenticated API user (and, for sub-users, the parent account).

```bash
curl https://onegate.ro/api/vs1/Admins/view.json \
  -H "Authorization: Bearer <token>"
```

---

## 3. Pagination, sorting & lists

List endpoints (`index`) accept these query parameters:

| Param | Description |
|-------|-------------|
| `page` | Page number (default `1`). |
| `limit` | Rows per page. Capped at `maxLimit = 100`. |
| `sort` | Field to sort by. Must be in the endpoint's sortable set (typically `id`, `name`, `created`, `modified`). |
| `direction` | `asc` or `desc`. |
| `format` | `format=list` returns a compact `{ id: name }` map instead of a paginated collection. |
| `keyField` / `valueField` | With `format=list`, choose which fields become the key/value (defaults `id` / `name`). |

Most list endpoints also accept `date_from` and `date_until` (filter on `created`) and a free-text `term`.

**Paginated example:**

```bash
curl "https://onegate.ro/api/vs1/Services/index.json?page=1&limit=25&sort=name&direction=asc" \
  -H "Authorization: Bearer <token>"
```

**List example:**

```bash
curl "https://onegate.ro/api/vs1/Currencies/index.json?format=list" \
  -H "Authorization: Bearer <token>"
```

```json
{ "status": 200, "data": { "1": "RON", "2": "EUR", "5": "GBP" } }
```

---

## 4. Catalog

### 4.1 Services

Top-level service categories (eSIM, vignette, toll, mobile prepay). Only active services are returned by `index`. The catalog (§4) is read-only through the API.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Services/index.json` | List services. Filters: `term`, `type` (int or `type[]=` array), `date_from`, `date_until`. |
| GET | `Services/view/{id}.json` | Single service. |

```bash
curl "https://onegate.ro/api/vs1/Services/index.json?type[]=4" \
  -H "Authorization: Bearer <token>"
```

### 4.2 Service Items

Individual purchasable products (`code` = stable slug). Only active items are returned by `index`.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `ServiceItems/index.json` | List items. |
| GET | `ServiceItems/view/{id}.json` | Single item. |

**`index` / `view` query parameters:**

| Param | Description |
|-------|-------------|
| `name` | Filter by name (`LIKE`). |
| `term` | Search across `code`, `name`, `description`. |
| `service_id` | Filter by parent service. |
| `currency_ids` | Comma-separated currency IDs to include in the formatted prices, e.g. `1,2,5`. |
| `with_contain` | `1` includes associated Services, Categories, Details, active Prices and active Media Files (§4.6). |
| `date_from` / `date_until` | Filter on `created`. |

```bash
curl "https://onegate.ro/api/vs1/ServiceItems/index.json?service_id=6&with_contain=1&sort=sort&direction=asc" \
  -H "Authorization: Bearer <token>"
```

```bash
curl "https://onegate.ro/api/vs1/ServiceItems/view/1234.json?currency_ids=1,2,5" \
  -H "Authorization: Bearer <token>"
```

### 4.3 Service Item Categories

| Method | Path | Purpose |
|--------|------|---------|
| GET | `ServiceItemCategories/index.json` | List. Filters: `term`, `service_id`, `date_from`, `date_until`, `with_contain`. |
| GET | `ServiceItemCategories/view/{id}.json` | Single category. |

### 4.4 Service Item Details

Flexible key-value attributes attached to a service item.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `ServiceItemDetails/index.json` | List. Filters: `term`, `date_from`, `date_until`. |
| GET | `ServiceItemDetails/view/{id}.json` | Single detail. |

On `ServiceItems` responses with `with_contain=1`, details are returned flattened as a `details` object (`{field: value}`).

**Standard detail fields for eSIM items** (`service_id = 5`). Missing values use the literal string `N/A`; JSON values are JSON strings to be decoded by the client.

| field | Example | Description |
|-------|---------|-------------|
| `duration` | `15` | Validity in days. |
| `data_amount` | `1000` | Data allowance in MB; `-1` = unlimited. |
| `speed` | `2G,3G,4G,5G` | Supported network speeds (CSV). |
| `countries` | `VA` / `Europe+` | Coverage: a 2-letter ISO country code, or a multi-country region code (e.g. `Europe+`, `Balkans`, `Global`, `US-HI`, `CYP`). |
| `countries_names` | `["Northern Cyprus"]` | Display names (JSON array), parallel to `countries` — use for region codes whose name differs from the code. |
| `roaming` | `AL,BA,BG,...` | Member countries covered by a region item (CSV of ISO codes); `N/A` for single-country items without roaming. |
| `operators` | `ILIAD Italia,Wind` | Mobile network brands (CSV, normalized). Single-country items only. |
| `billing_type` | `FixedCost` | Billing model of the plan. |
| `is_autostart` | `0`/`1` | Plan starts automatically on install. |
| `is_unlimited` | `0`/`1` | Unlimited-data plan. |
| `allowances` | `[{"type":"DATA","service":"ROAMING","amount":1000,"unit":"MB","is_unlimited":false}]` | Allowance breakdown (JSON array). |

**Standard detail fields for Bulgaria vignette items** (`service_id = 3`). Two vehicle categories × six durations = 12 items.

| field | Values | Description |
|-------|--------|-------------|
| `vehicle_category` | `car` / `trailer` | `car` = vehicle up to 3.5 t (incl. SUVs, vans, motorhomes); `trailer` = trailer or caravan. |
| `duration_code` | `day` / `weekend` / `week` / `month` / `quarter` / `year` | Vignette duration. |
| `duration_days` | `1` / `3` / `7` / `30` / `90` / `365` | Informative length in days. |
| `country` | `BG` | Country where the vignette applies. |
| `requires_start_time` | `0` / `1` | `1` (daily vignette only): the order accepts a `start_time` (see §5, Order item details). |

**Standard detail fields for Hungary vignette items** (`service_id = 4`, e-matrica). Four vehicle categories × four durations = 16 items, codes `HU-VIGN-{D1|D2|D1M|U}-{DAY|TEN_DAYS|MONTH|YEAR}`. Catalog only for now: the order rules (§5) are not in place yet. Prices: in HUF the official price, 1:1; in RON a fixed total; EUR/USD/GBP converted from RON at the day's rate.

| field | Values | Description |
|-------|--------|-------------|
| `vehicle_category` | `d1` / `d2` / `d1m` / `u` | `d1` = car up to 3.5 t and 7 seats; `d2` = everything above (vans, minibuses, motorhomes not in D1); `d1m` = motorcycle; `u` = trailer of a D2 vehicle (a D1's trailer needs no vignette). |
| `duration_code` | `day` / `ten_days` / `month` / `year` | Vignette duration; `year` = national annual, valid until 31 January of the next year. |
| `duration_days` | `1` / `10` / `31` / `365` | Informative length in days (month and year are calendar-based). |
| `country` | `HU` | Country where the vignette applies. |
| `requires_start_time` | `0` | Always `0`: no start time on any duration. |

### 4.5 Service Item Prices

Per-currency prices for a service item.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `ServiceItemPrices/index.json` | List. Filters: `term`, `date_from`, `date_until`. |
| GET | `ServiceItemPrices/view/{id}.json` | Single price. |

### 4.6 Media Files

Read-only media files (images, PDFs) attached polymorphically to entities — currently service items (`entity_type = 4`). Only active files are returned. The `url` field is the single, final URL to use — always neutral (`onegate.ro`, the OneGate CDN, or a Digital WAP storefront CDN), never a provider URL. URLs are **stable** (no `?vs=` query); the `vs` field is a change-detection counter you can watch to know a file was replaced (it is not part of the URL). A service item exposes one media file per language (e.g. `lang: "ro"` and `lang: "en"`); `lang: null` means the file applies to all languages.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `MediaFiles/index.json` | List. Filters: `entity_type`, `foreign_key` (int or `foreign_key[]=` array — bulk sync), `type` (`image`/`pdf`/`video`/`other`), `lang` (string or array; files with `lang = null` apply to all languages), `term`, `date_from`, `date_until`. |
| GET | `MediaFiles/view/{id}.json` | Single media file. |

```bash
curl "https://onegate.ro/api/vs1/MediaFiles/index.json?entity_type=4&foreign_key[]=1234&foreign_key[]=1235&type=image&lang=en" \
  -H "Authorization: Bearer <token>"
```

```json
{
  "status": 200,
  "data": [
    {
      "id": 17,
      "entity_type": 4,
      "foreign_key": 1234,
      "type": "image",
      "lang": "en",
      "name": "card",
      "mime": "image/webp",
      "size": null,
      "width": null,
      "height": null,
      "sort": 1,
      "vs": 1,
      "is_active": true,
      "url": "https://beesim.b-cdn.net/en/esim_1GB_7D_AD_V2.webp"
    }
  ],
  "total": 1
}
```

The same objects are embedded as `media_files` on `ServiceItems/index?with_contain=1` and `ServiceItems/view/{id}` responses. Note: `total` mirrors the other list endpoints (page-level count); the full match count is in `pagination.counter` / `pagination.pages`.

---

## 5. Orders

An order groups one or more order items and carries the billing snapshot. Orders are identified by numeric `id` **or** by `uuid` — `view`, `edit`, `process` and `track` accept either.

> **Guest capability token.** For the shared guest account (storefront integrations), every order-scoped call — Orders `view`/`edit`/`process`/`track`/`delete`, Transactions `add`/`view`/`edit`/`processPayment`/`delete`, OrderItems and InvoiceAddresses writes/reads — additionally requires the order's `token` (returned once by `Orders/add` and by `Orders/view`), sent as a query or body parameter: `?token=<32-char-token>`. A missing or wrong token returns `404`. `index` endpoints on these resources return `404` for the guest identity. Tenant (vendor) accounts are unaffected. See `docs/guest-links.md`.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Orders/index.json` | List orders. Filters: `uuid`, `status_id`, `site_id`, `date_from`, `date_until`. |
| GET | `Orders/view/{id-or-uuid}.json` | Single order with items, item details and invoice address, plus `paid_vat_value`, `is_invoiced` and `invoice_url` (see below). |
| POST | `Orders/add.json` | Create an order (with nested order items). |
| PUT | `Orders/edit/{id-or-uuid}.json` | Update (see [Change an order](#change-an-order)). |
| DELETE | `Orders/delete/{id}.json` | Delete an order that is still `NEW`. |
| POST | `Orders/process/{id-or-uuid}.json` | Trigger immediate fulfillment of a **paid** order (see below). |
| PUT | `Orders/billing/{uuid}.json` | Billing data added by the customer **after paying** (see below). |
| POST | `Orders/track/{uuid}.json` | Mark a paid/processing/completed order as tracked (once). |

> **Invoice.** `is_invoiced` is `true` once Probill issued a document for the order. `invoice_url` is the public PDF of the order's invoice (the newest one that is not a reversal), or `null`. Transactions themselves are not part of the response. The invoice address carries its country as `invoice_address.country` (`id`, `iso`, `name`).

> **Amounts.** `net_value`, `vat_value` and `total_value` on the order and its items are the **list price**: no discount, and for a rebilled service (Bulgarian vignette) the VAT applies only to the part above the supplier cost. A discount arrives with the payment, in `discount_value`, so the amount collected is `total_value − discount_value`. `paid_vat_value` is the VAT of the amount collected, the same as on the invoice; it is `null` while the order is unpaid. Report that value as tax (e.g. GA4 `purchase.tax`). Rules: `docs/order-flow.md` → Reguli fiscale.

### Create an order

Send order data with nested `order_items`:

```bash
curl -X POST https://onegate.ro/api/vs1/Orders/add.json \
  -H "Authorization: Bearer <token>" \
  -d "site_id=10" \
  -d "currency_id=2" \
  -d "lang=en" \
  -d "order_items[0][service_item_id]=1234" \
  -d "order_items[0][quantity]=1"
```

```json
{
  "status": 200,
  "message": "The entry has been saved.",
  "data": {
    "id": 5001,
    "uuid": "A1B2C3D4E5",
    "site_id": 10,
    "currency_id": 2,
    "status_id": 1,
    "net_value": "12.84",
    "vat_value": "2.70",
    "total_value": "15.54",
    "order_items": [ { "id": 9001, "service_item_id": 1234, "quantity": 1, "status_id": 101, "total_value": 15.54 } ]
  }
}
```

The response carries the order totals (`net_value`, `vat_value`, `total_value`) computed by OneGate: that is the amount you pay.

**Accepted fields.** `site_id`, `currency_id`, `lang`, `payment_method`, `email`, `phone`, `is_terms`, `is_withdrawal_waived`, `is_marketing`, `is_billing_requested`, `ip_address`, `source_type`, `source_type_ref`, `order_items[]` (`service_item_id`, `quantity`, `details`) and `invoice_address`. A new order always starts as `NEW` (1), with its items `NEW` (101); prices come from the catalog. Anything else in the payload is ignored.

> Note: for non-RON orders, `exchange_rate` is set automatically to the live BNR rate on save — don't send it from the client.

#### Consents

| field | Required | Values | Meaning |
|-------|----------|--------|---------|
| `is_terms` | yes | `1` / `true` | The customer accepted the terms and conditions. |
| `is_withdrawal_waived` | no | `1` / `true` | The customer asked for immediate performance and acknowledged that the right of withdrawal ends once the service is performed (OUG 34/2014 art. 16 lit. a). Send it when your checkout collects that consent (the Bulgaria vignette storefront does, on every order). |
| `is_billing_requested` | no | `1` / `0` | The customer's answer to an optional "I want an invoice with my details" checkbox. `1`: the card payment page asks for the billing address and tax ID; `0`: it does not. Leave it out (stored as `NULL`) when your checkout has no such checkbox: the payment page then follows the service's default. |

- `is_withdrawal_waived` is stored only when the order is created. If you leave it out, it is stored as empty (`NULL`) and the order goes through as usual. `Orders/edit` never changes it.
- `is_billing_requested` can also be changed with `Orders/edit` while the order is `NEW`; it applies to the next `processPayment`.
- If you send `is_withdrawal_waived`, it must be `1` or `true`. Any other value (`0`, `false`, `""`, `abc`) returns `400` with an error on the field, in the same form as `is_terms`:

```json
{ "status": 400, "errors": { "is_withdrawal_waived": { "equals": "The provided value must be equal to `1`" } } }
```

None of these fields is included in responses.

### Change an order

`PUT Orders/edit/{id-or-uuid}.json` accepts only these fields; anything else in the payload is ignored:

| field | Notes |
|-------|-------|
| `email`, `phone`, `lang`, `is_marketing`, `is_terms`, `is_billing_requested` | Contact and checkout data. |
| `payment_method` | While the order is `NEW`. |
| `order_items[]` | `service_item_id`, `quantity`. Without `id` the entry adds an item; with the `id` of one of the order's items it changes that item. Items are repriced from the catalog. Item details cannot be changed (see [Order item details](#order-item-details)). |
| `invoice_address` | Billing snapshot. To complete it after payment, use [`Orders/billing`](#billing-data-after-payment). |

The site, currency, statuses and amounts of an order cannot be changed through the API. The response carries the recalculated totals.

**Once the order is paid, its content is final.** Changing its items or payment method (through `Orders/edit` or the `OrderItems` endpoints) returns `409` with `code: "locked"`. Contact data stays editable on partner accounts. On the shared storefront account, an order accepts no edit once it leaves `NEW`.

```json
{ "status": 409, "code": "locked", "message": "The order can no longer be changed." }
```

**Changed an unpaid order? Create a new transaction.** A transaction takes the order total when it is created (§6). If the order changes after that, `processPayment` refuses the older transaction with `400`; create a new one with `Transactions/add`.

### Process an order

`POST Orders/process/{id-or-uuid}.json` triggers fulfillment immediately instead of waiting for the CRON cycle. It is idempotent and safe to call concurrently — each item is claimed atomically and processed exactly once.

- `200` — processed (`data` = fresh order snapshot).
- `400` — order is not in a processable (paid) state.
- `404` — order not found.
- `409` — already being processed by another worker.

Items of services fulfilled by hand (today: Bulgaria and Hungary vignettes) are handed over to an operator instead: the response is `200` with `status_id = 5` (PROCESSING), and the order stays PROCESSING until the operator completes it — then it moves to COMPLETED like any other order. Poll `Orders/view` to follow it.

```bash
curl -X POST https://onegate.ro/api/vs1/Orders/process/A1B2C3D4E5.json \
  -H "Authorization: Bearer <token>"
```

### Billing data after payment

`PUT Orders/billing/{uuid}.json?token={token}` completes the invoice address of a **paid** order, once. It exists for orders paid through a Checkout that does not ask for the billing address (`collect_billing = false`, `docs/order-flow.md`): Stripe leaves only the cardholder's name and country, and Probill does not issue an invoice without a street. The invoice is issued by the next invoicing CRON (every 5 minutes); attempts used up by earlier refusals are reset.

| Field | Person (`company_type = 1`) | Company (`company_type = 2`) |
|---|---|---|
| `company_type` | `1` | `2` |
| `country` | ISO 3166-1 alpha-2, required | ISO 3166-1 alpha-2, required |
| `name` | required | required, except for a Romanian company found in the register |
| `cui` | ignored | required (with or without `RO`) |
| `regcom` | ignored | optional |
| `county` | required (for Romania: a county, e.g. `Cluj`, `București`) | as for a person |
| `locality` | required (in București: `Sector 1` … `Sector 6`) | as for a person |
| `address` | required | as for a person |

For a **Romanian company**, the name, Reg. Com., VAT status and registered office come from the trade register by CUI and replace what was sent. When the register does not know the CUI, the fields sent are used. Any other field in the body is ignored; a CNP is never accepted.

- `200` - saved; `data` = the snapshot as stored (completed from the register, CNP always `null` for the guest).
- `400` - `errors` by payload field (`country`, `name`, `cui`, `county`, `locality`, `address`), with the validator's rule keys (`_empty`, `_required`, `custom`, `maxLength`, `registry` = CUI not found in the register and no name sent).
- `404` - unknown order or wrong token.
- `409` with `code`: `not_paid` (the order is not paid, or was refunded / closed), `invoiced` (an invoice was issued: the data is frozen, corrections go through an operator), `locked` (the snapshot already has a street or a CUI).

```bash
curl -X PUT "https://onegate.ro/api/vs1/Orders/billing/A1B2C3D4E5.json?token=<order-token>"   -H "Authorization: Bearer <token>"   -H "Content-Type: application/json"   -d '{"company_type": 2, "country": "RO", "cui": "36145517"}'
```

### Order Items

| Method | Path | Purpose |
|--------|------|---------|
| GET | `OrderItems/index.json` | List. Filters: `term`, `date_from`, `date_until`. |
| GET | `OrderItems/view/{id}.json` | Single item. |
| GET | `OrderItems/usage/{id}.json` | Usage/consumption summary for a fulfilled item (see below). |
| POST | `OrderItems/add.json` | Add an item to one of your `NEW` orders (`order_id`, `service_item_id`, `quantity`). |
| PUT | `OrderItems/edit/{id}.json` | Change `service_item_id` / `quantity` of an item of a `NEW` order. |
| DELETE | `OrderItems/delete/{id}.json` | Remove an item from a `NEW` order. |

#### Order item usage

`GET OrderItems/usage/{id}.json` — real-time usage summary for a fulfilled order item, currently supported for eSIM items (data consumption). You send only the order item `id` — never a technical identifier; the server resolves everything internally. For guest orders, the order's `?token=` is required, like every other order-scoped call.

```bash
curl "https://onegate.ro/api/vs1/OrderItems/usage/9001.json" \
  -H "Authorization: Bearer <token>"
```

```json
{
  "status": 200,
  "data": {
    "status": "normal",
    "status_message": "Good",
    "iccid": "8944000000000000000",
    "bundle_name": "esim_10GB_30D_TR_V2",
    "bundle_description": "eSIM, 10GB, 30 Days, Turkey",
    "data": { "total_gb": 10, "used_gb": 5.2, "remaining_gb": 4.8, "usage_percentage": 52 },
    "validity": { "start": "2026-07-01T00:00:00Z", "end": "2026-07-31T00:00:00Z", "is_expired": false }
  }
}
```

If the eSIM has no active bundles (not yet installed, or fully expired), `data.status` is `no_active_bundles` / `no_data` with a `message` — still a `200`. Errors: `400` — the item's service has no usage function, or the product has not been issued yet; `502` — usage information temporarily unavailable.

### Order item details

Order item details are the parameters the customer enters for an item (e.g. `plate_number`, `start_date` for a vignette). They have no endpoint of their own:

- **Write** — only when the order is created, in `Orders/add`, as `order_items[i][details][field]=value`.
- **Read** — returned as `order_items[].order_item_details` in `Orders/view` and as `order_item_details` in `OrderItems/view`.
- They **cannot be changed through the API** after the order is created.

```bash
curl -X POST https://onegate.ro/api/vs1/Orders/add.json \
  -H "Authorization: Bearer <token>" \
  -d "site_id=10" \
  -d "currency_id=2" \
  -d "lang=en" \
  -d "order_items[0][service_item_id]=1234" \
  -d "order_items[0][details][plate_number]=B123ABC" \
  -d "order_items[0][details][plate_country]=RO" \
  -d "order_items[0][details][start_date]=2026-10-01"
```

Some services validate their details when the order is created. The server normalizes what it can (and returns the stored values), and rejects the rest with a `400` (see below). Items of these services can only be created together with their order: adding one to an existing order, or switching an item to another product later, returns `400` on `service_item_id`.

#### Bulgaria vignette details (`service_id = 3`)

| field | Required | Format | Normalization |
|-------|----------|--------|---------------|
| `plate_number` | yes | Letters and digits only, 2–12 characters | Uppercased; spaces, hyphens and dots removed; Latin diacritics reduced (`ă` → `A`); Cyrillic letters that look Latin mapped by shape (`С` → `C`, as on Bulgarian plates). |
| `plate_country` | yes | ISO 3166-1 alpha-2 (see §9.3) | Uppercased. |
| `start_date` | yes | `YYYY-MM-DD`, from today to today + 30 days | Weekend vignettes are moved to the Friday of their weekend (Monday–Friday → the coming Friday, Saturday/Sunday → the Friday just past); a weekend is accepted until its Sunday. |
| `start_time` | daily only | `HH:MM` (Europe/Sofia = Europe/Bucharest time) | Missing, or already past today → the current time. Removed for every other duration. |

Unknown fields are dropped. Per order:

- 1 or 2 items, all Bulgaria vignettes (no other products in the same order), `quantity` = 1;
- at most one `car` and one `trailer`;
- a car and its trailer travel together: same duration, same `start_date` and `start_time`, different `plate_number`.

Errors are keyed by the item's index in the request; order-level errors sit under `_order`:

```json
{
  "status": 400,
  "message": "The entry could not be saved. Please try again.",
  "errors": {
    "order_items": {
      "0": { "details": { "plate_country": { "exists": "Unknown country of registration." } } },
      "1": { "details": { "plate_number": { "different": "The trailer must have a different registration number than the car." } } },
      "_order": { "sameDuration": "The car and the trailer must have the same vignette duration." }
    }
  }
}
```

#### Hungary vignette details (`service_id = 4`)

| field | Required | Format | Normalization |
|-------|----------|--------|---------------|
| `plate_number` | yes | Letters and digits only, 2–12 characters | As for the Bulgaria vignette. |
| `plate_country` | yes | ISO 3166-1 alpha-2 (see §9.3) | Uppercased. |
| `start_date` | yes | `YYYY-MM-DD`, in **Hungary time** (Europe/Budapest, one hour behind Romania); window below | Annual vignette: a date of the current year already past, or today, becomes today. |

There is no `start_time`: it is dropped, like any unknown field. Every vignette runs in whole days.

| `duration_code` | Accepted `start_date` (Hungary time) |
|---|---|
| `day` | today … today + 30 days; today only until 20:00, from then on tomorrow … today + 30 |
| `ten_days`, `month` | today … today + 30 days |
| `year` | January–November: today only. December: only 1 January of the next year, and only from the official presale date (yearly, e.g. 8 December); before it no annual vignette is sold |

Per order: **exactly one item**, a Hungary vignette (any category, a trailer `u` alone included), `quantity` = 1, no other products.

Errors have the same form as for the Bulgaria vignette. The rule keys:

| Key | Where | Meaning |
|---|---|---|
| `_order.oneVignette` | order | More than one Hungary vignette in the order. |
| `_order.sameService` | order | The order mixes the vignette with other products. |
| `quantity.one`, `details.plate_number._required` / `format`, `details.plate_country._required` / `exists`, `details.start_date._required` / `format` | item | As for the Bulgaria vignette. |
| `details.start_date.window` | item | Outside the window above; the message names the accepted dates. |
| `details.start_date.presale` | item | Annual vignette in December, before the presale of next year opens. |

```json
{
  "status": 400,
  "errors": {
    "order_items": [
      { "details": { "start_date": { "window": "Choose a start date between 2026-10-22 and 2026-11-21." } } }
    ]
  }
}
```

#### Details added at fulfilment

Once a vignette item is `COMPLETED` (status 107), `order_item_details` also holds the rows written by our operator. They come from the supplier's proof and are the **source of truth for what the customer bought**:

| field | Format | Meaning |
|-------|--------|---------|
| `provider_ref` | text | The vignette's ID at the issuer (Bulgaria: the BGTOLL ID that check.bgtoll.bg shows). |
| `valid_from` | `YYYY-MM-DD HH:MM:SS`, in the service's timezone (below) | Start of the **registered** validity. |
| `valid_until` | `YYYY-MM-DD HH:MM:SS`, same timezone | End of the registered validity, inclusive (e.g. `2026-09-26 12:21:59`). |

**Timezone:** the one printed on the issuer's proof. Bulgaria vignette: Europe/Sofia = Europe/Bucharest. Hungary vignette: **Europe/Budapest** (one hour behind Romania), e.g. a daily vignette `2026-10-25 00:00:00` → `2026-10-25 23:59:59`, Hungary time.

Show `valid_from` / `valid_until` when present, instead of an interval computed from `start_date` / `start_time`: those hold what the customer asked for, and the registered interval can differ. A Bulgarian daily vignette starts when it is paid on the supplier's side, which can be later than the requested time, and it still ends 24 hours after the requested time. Example: requested 12:22, registered from 12:26:00 to 12:21:59 the next day. The rows can be corrected later by an operator, so read them from `Orders/view` rather than caching them for good. Items completed before 2026-09-25 may have `provider_ref` only.

---

## 6. Payments (Transactions)

A transaction represents the payment attached to an order. Identified by numeric `id` or `uuid`.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Transactions/index.json` | List. Filters: `uuid`, `date_from`, `date_until`. |
| GET | `Transactions/view/{id-or-uuid}.json` | Single transaction. |
| POST | `Transactions/add.json` | Create a transaction for an order. |
| PUT | `Transactions/processPayment/{id-or-uuid}.json` | Process the payment (see below). |

> **Transactions are an append-only ledger — they cannot be edited or deleted.** `PUT Transactions/edit` and `DELETE Transactions/delete` always return `403 Forbidden`, for every account type. Editing or deleting a paid transaction would silently rewrite the account balance (the `accounts.balance` counter is derived from the transaction rows) and break the link to any issued invoice. A payment is reversed only by an internal reversal (compensating transaction + fiscal storno), never through the API.
>
> On `add`, all money-affecting fields are set server-side: a new transaction is always created `NEW`/unpaid, with its amounts derived from the linked order. `is_paid`, `is_active`, `net_value`, `total_value`, `transaction_type` etc. sent in the payload are ignored.

### Create a transaction

```bash
curl -X POST https://onegate.ro/api/vs1/Transactions/add.json \
  -H "Authorization: Bearer <token>" \
  -d "order_id=5001"
```

### Process payment

`PUT Transactions/processPayment/{id-or-uuid}.json` — the branch is selected from `payment_method` first, then the account type:

- **`payment_method = CARD`** (any account type) → starts a Stripe Checkout session and returns a `payment_url` in `data`; the transaction is finalised later by the Stripe webhook. Internal accounts always use this branch; a vendor may opt into it by setting `payment_method = CARD` explicitly on the order (default for vendors is `OP`).
- **else vendor account** → immediate completion. The transaction is marked paid and the order moves to `PAID`. Prepay vendors get a race-safe balance pre-check; insufficient balance returns `400`. Postpay vendors with a credit limit get the same check against it (`400` "Credit limit exceeded"). No `payment_url`.
- Anything else → `400 Unsupported payment method`.

Only transactions with status `new` (and no existing `code`) can be processed — otherwise `400`. A transaction created before the order last changed is also refused with `400`; create a new one (§5, [Change an order](#change-an-order)).

```bash
curl -X PUT https://onegate.ro/api/vs1/Transactions/processPayment/9f8e7d6c.json \
  -H "Authorization: Bearer <token>"
```

```json
{
  "status": 200,
  "message": "The entry has been saved.",
  "data": { "payment_url": "https://checkout.stripe.com/c/pay/dummy_session" }
}
```

**Payment provider errors (card branch).** If the Stripe Checkout session cannot be created, `processPayment` answers with a short `message` and no `data`:

| Code | Meaning |
|------|---------|
| `503 Service Unavailable` | The payment provider could not be reached (network/DNS). |
| `502 Bad Gateway` | The payment provider returned an error. |

In both cases the transaction stays `new` with no `code`, so you can retry the same `processPayment` call later.

```json
{
  "status": 503,
  "message": "The payment provider is temporarily unavailable. Please try again."
}
```

---

## 7. Order flow (end-to-end)

Placing and fulfilling an order is a fixed sequence of four calls. The **same endpoints are used for every account type** — the server picks the card (async) or vendor (sync) branch internally based on the account that owns the order, so as an integrator you send the same requests either way and only the responses differ.

```
  POST Orders/add                → Order      status = NEW
  POST Transactions/add          → Transaction status = NEW
  PUT  Transactions/processPayment → pay (branches below)
  POST Orders/process/{uuid}     → fulfill paid order (or wait for CRON)
```

### Step 1 — Create the order

```bash
curl -X POST https://onegate.ro/api/vs1/Orders/add.json \
  -H "Authorization: Bearer <token>" \
  -d "site_id=10" \
  -d "currency_id=2" \
  -d "lang=en" \
  -d "order_items[0][service_item_id]=1234" \
  -d "order_items[0][quantity]=1"
```

The order comes back with `status_id = 1` (NEW) and a `uuid` you'll use for the rest of the flow. Keep the numeric `id` too — you need it for step 2.

> For **prepay vendor** accounts the balance is pre-checked here: if `balance < total_value`, `add` fails with a `400` and an `account_id` error (`"Insufficient balance. Available: X, required: Y."`). No balance is deducted yet.
> `payment_method` is optional for vendors (auto-defaults to `OP`); the card frontend sets `PAYMENT_METHOD_CARD` on the order.
> **Services sold to vendors at cost** (today the Bulgarian vignette, `SERVICE_BGVI`): the catalog shows a vendor account the supplier cost (VAT 0), and such an order is accepted only from a **postpay** vendor paying on account (`OP`). Otherwise `400` with an error on `payment_method.b2bTerms`. The costs and a monthly commission are billed on the monthly invoice (`docs/b2b.md`).

### Step 2 — Create the transaction

```bash
curl -X POST https://onegate.ro/api/vs1/Transactions/add.json \
  -H "Authorization: Bearer <token>" \
  -d "order_id=5001"
```

Returns a transaction with `status_id` NEW. This is the payment record you'll process next.

### Step 3 — Process the payment

`PUT Transactions/processPayment/{id-or-uuid}.json`. Only a transaction that is still `new` (and has no `code` yet) can be processed. From here the two branches diverge:

**Card (internal accounts, e.g. beesim.ro) — asynchronous**

```json
{
  "status": 200,
  "message": "The entry has been saved.",
  "data": { "payment_url": "https://checkout.stripe.com/c/pay/dummy_session" }
}
```

- Redirect the buyer to `payment_url` (Stripe Checkout).
- The transaction stays `PENDING`; **the order is NOT `PAID` yet.**
- When Stripe confirms, its webhook (`checkout.session.completed`) marks the transaction `SUCCEEDED` and the order `PAID` — server-side, no API call from you. If the buyer abandons checkout, `checkout.session.expired` fires and the transaction becomes `EXPIRED`.
- To learn the outcome, poll `GET Orders/view/{uuid}.json` (or `GET Transactions/view/{uuid}.json`) until the status changes.

**Vendor (B2B, wallet/credit) — synchronous**

```json
{
  "status": 200,
  "message": "The entry has been saved.",
  "data": { "id": 9001, "uuid": "9f8e7d6c", "status_id": 205, "order": { "status_id": 4 } }
}
```

- No `payment_url` — payment completes inline. By the time you get the `200`, the order is already `PAID`.
- Prepay wallets get a race-safe balance re-check here (`SELECT … FOR UPDATE`); insufficient funds return `400`. Postpay accounts are billed later by the monthly billing job.

### Step 4 — Fulfill the order

Once the order is `PAID`, its items are `AWAITING_PROCESSING`. Fulfillment (e.g. eSIM delivery) runs automatically on the CRON cycle, or you can trigger it immediately:

```bash
curl -X POST https://onegate.ro/api/vs1/Orders/process/9f8e7d6c.json \
  -H "Authorization: Bearer <token>"
```

This is idempotent and safe to call alongside the CRON — each item is claimed atomically and processed exactly once. Responses: `200` processed, `400` not in a processable (paid) state, `404` not found, `409` already being processed. See the [Process an order](#process-an-order) reference in §5 for the response shape.

For services fulfilled by hand (Bulgaria vignettes), processing hands the order to an operator: it stays PROCESSING (5) until they complete it, then becomes COMPLETED (7).

### Status reference

Poll `Orders/view` / `Transactions/view` and read `status_id` against these transitions:

```
Order:       NEW(1) → PAID(4) → PROCESSING(5) → COMPLETED(7) | PARTIALLY_COMPLETED(6) | CANCELED(8)
OrderItem:   NEW(101) → AWAITING_PROCESSING(103) → PROCESSING(104) → COMPLETED(107) | FAILED(108)
Transaction: NEW(201) → PENDING(202) → SUCCEEDED(205) | EXPIRED(211)   [card]
             NEW(201) → SUCCEEDED(205) directly                        [vendor]
```

> Fetch human-readable status names via `GET Statuses/index.json?format=list&entity_type=...` (§9.4) rather than hard-coding these numeric IDs.

### Optional — mark an order as tracked

After an order is paid/processing/completed you can flag it as tracked exactly once with `POST Orders/track/{uuid}.json` (returns `409` if already tracked, `400` for a wrong status).

---

## 8. Content (Blog)

Multilingual CMS content served to partner sites. Only published posts (`status=published`, `published_at` in the past) are returned.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `BlogPosts/index.json` | List posts with active blocks. Filters: `term` (slug/title/subtitle), `site_id`, `lang`, `page`. |
| GET | `BlogPosts/view/{id}.json` | Single post by id. |
| GET | `BlogPosts/view.json?slug=...` | Single post by slug (omit the id, pass `?slug=`). |
| GET | `BlogPosts/slugs.json` | All published slugs, grouped by post id and locale. Filter: `site_id`. |

```bash
curl "https://onegate.ro/api/vs1/BlogPosts/view.json?slug=what-is-esim-and-how-does-it-work&lang=en" \
  -H "Authorization: Bearer <token>"
```

```bash
curl "https://onegate.ro/api/vs1/BlogPosts/slugs.json?site_id=10" \
  -H "Authorization: Bearer <token>"
```

---

## 9. Reference data

### 9.1 Sites

Partner sites connected to the account.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Sites/index.json` | List. Filters: `term`, `date_from`, `date_until`. Supports `format=list`. |
| GET | `Sites/view/{id}.json` | Single site. |

### 9.2 Currencies

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Currencies/index.json` | List (supports `format=list`). Filters: `term`, `date_from`, `date_until`. |
| GET | `Currencies/view/{id}.json` | Single currency. |

### 9.3 Countries

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Countries/index.json` | List (supports `format=list`, `lang`). Filters: `term`, `date_from`, `date_until`. |
| GET | `Countries/view/{id}.json` | Single country. |

```bash
curl "https://onegate.ro/api/vs1/Countries/index.json?format=list&lang=en" \
  -H "Authorization: Bearer <token>"
```

### 9.4 Statuses

Status definitions per entity type (order, order item, transaction…).

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Statuses/index.json` | List (supports `format=list`). Filter: `entity_type`. |

```bash
curl "https://onegate.ro/api/vs1/Statuses/index.json?format=list&entity_type=1" \
  -H "Authorization: Bearer <token>"
```

### 9.5 Events

Polymorphic state-change log. Read-only. `variables_json` is decoded into a `variables` object in the response.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Events/index.json` | List. Filters: `entity_type`, `foreign_key`, `date_from`, `date_until`. Supports `format=list`. |

### 9.6 Providers

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Providers/index.json` | List. Filters: `term`, `date_from`, `date_until`. |
| GET | `Providers/view/{id}.json` | Single provider. |

### 9.7 Ratings

| Method | Path | Purpose |
|--------|------|---------|
| GET | `Ratings/index.json` | List. Filters: `term`, `date_from`, `date_until`. |
| GET | `Ratings/view/{id}.json` | Single rating. |
| POST | `Ratings/add.json` | Create. |
| PUT | `Ratings/edit/{id}.json` | Update. |
| DELETE | `Ratings/delete/{id}.json` | Delete. |

### 9.8 Invoice Addresses

Billing addresses (snapshot per order + vendor billing profile). An order's billing snapshot is written with the order (`invoice_address` in `Orders/add` / `Orders/edit`, §5) or, once paid, through `Orders/billing`.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `InvoiceAddresses/index.json` | List. Filters: `term`, `date_from`, `date_until`. |
| GET | `InvoiceAddresses/view/{id}.json` | Single address. |

### 9.9 Company lookup (RegcomCompanies)

Read-only registry company lookup.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `RegcomCompanies/index.json` | List. Filters: `term`, `date_from`, `date_until`. |
| GET | `RegcomCompanies/view/{id}.json` | Single company. |

### 9.10 IP information

| Method | Path | Purpose |
|--------|------|---------|
| GET | `IpAddresses/info/{ip}.json` | Geolocation/info for an IP. Pass the IP in the path or `?ip=`; if omitted, the caller's IP is used. |

```bash
curl "https://onegate.ro/api/vs1/IpAddresses/info.json?ip=8.8.8.8" \
  -H "Authorization: Bearer <token>"
```

---

## 10. Errors

Errors use the same envelope; `status` mirrors the HTTP code.

| Code | Meaning |
|------|---------|
| `400 Bad Request` | Validation failed (`errors` map) or an invalid state/argument (e.g. transaction not in a processable state, unsupported payment method, insufficient balance). |
| `401 Unauthorized` | Missing/invalid/expired token, or the login credentials were rejected. |
| `403 Forbidden` | Authenticated but not allowed to access this resource (authorization policy / tenant scope). |
| `404 Not Found` | Resource does not exist (or is not visible to your account). |
| `405 Method Not Allowed` | Wrong HTTP verb for the action. |
| `406 Not Acceptable` | The `.json`/`.xml` extension is missing. |
| `409 Conflict` | Duplicate/again-in-progress operation (e.g. order already processed or already tracked), or a change to an order that is no longer `NEW` (`code: "locked"`). |

**Validation error example:**

```json
{
  "status": 400,
  "message": "The entry could not be saved. Please try again.",
  "errors": {
    "email": { "_required": "This field is required" }
  }
}
```

---

## 11. Versioning

This reference and the API share one version number, following semantic versioning: **major** for a breaking change to an existing call (served under a new base path), **minor** for new endpoints, fields or parameters, **patch** for clarifications and corrections that keep the contract. `Hello/index` and `Admins/login` report it in `version`; it always matches the version at the top of this document.

| Version | Date | Changes |
|---------|------|---------|
| 1.1.0 | 2026-10-13 | `is_billing_requested` on `Orders/add` and `Orders/edit`: the customer's choice to enter billing details on the card payment page. |
| 1.0.1 | 2026-10-12 | `site_id` is set when the order is created (the ID received at onboarding) and is no longer accepted by `Orders/edit`. |
| 1.0.0 | 2026-10-12 | First versioned edition: the fields accepted by `Orders/add`, `Orders/edit` and the `OrderItems` writes, `409 locked` for orders that left `NEW`, and the transaction rule of `processPayment`. Catalog and reference data are listed as read-only. |
