# Откуцај (Otkucaj) ESIR API v1

Откуцај is a Serbian ESIR (electronic fiscal cash register) web application, live at
<https://otkucaj.com>. This document is the complete reference for its public REST API.

Machine readable version of this document: <https://otkucaj.com/openapi.json> (OpenAPI 3.1).
Serbian Cyrillic documentation for humans: <https://otkucaj.com/api-docs>.

> **Fiscalization status.** Откуцај is **not yet approved** by the Poreska uprava (Serbian Tax
> Administration). Unless an installation has been switched to L-PFR or V-PFR in its settings, it
> runs against the built-in demo PFR simulator: every receipt is marked **ДЕМО**, `pfr.demo` is
> `true`, and such a receipt has **no fiscal validity**. Call `GET /status` to see which mode an
> installation is in. Nothing in this API certifies an invoice as fiscal on its own.

---

## Quickstart

```bash
curl -X POST https://otkucaj.com/api/v1/invoices \
  -H "Authorization: Bearer kasir_VAS_API_KLJUC" \
  -H "Content-Type: application/json" \
  -d '{
    "invoiceType": "Normal",
    "transactionType": "Sale",
    "cashier": "Касир 1",
    "items": [
      {"name": "Кафа еспресо", "unit": "ком", "quantity": 2, "unitPrice": 180.00, "label": "Ђ"},
      {"name": "Негазирана вода 0,33", "unit": "ком", "quantity": 1, "unitPrice": 120.00, "label": "Е"}
    ],
    "payments": [{"paymentType": "Cash", "amount": 480.00}]
  }'
```

Answer: `201` with the invoice, including its PFR number, counter, verification link and the
full printable journal.

Check that your key works first:

```bash
curl https://otkucaj.com/api/v1/me -H "Authorization: Bearer kasir_VAS_API_KLJUC"
```

---

## Conventions

| Topic | Value |
| --- | --- |
| Base URL | `https://otkucaj.com/api/v1` |
| Auth | `Authorization: Bearer <api key>` on **every** route |
| Content type | `application/json` in and out, except the journal route which returns `text/plain` |
| Rate limit | 240 requests per minute per key. No `Retry-After` header |
| Mail limit | 30 messages per hour per key on the e-mail route |
| Currency | RSD, all amounts **include VAT** |
| Time zone | Europe/Belgrade. Timestamps are `YYYY-MM-DD HH:MM:SS` |
| Case | Enum values are case sensitive: `Normal`, not `NORMAL` |
| Character set | UTF-8. Item names, cashier names and tax labels are Serbian Cyrillic |

### Keys

API keys are created and revoked inside the application under Подешавања, by an administrator.
A key looks like `kasir_` followed by 48 hex characters, is shown **once** at creation, and only
its SHA-256 hash is stored. Keys are **not scoped individually**: every active key may call every
route in this API. Revoking a key takes effect on the next request.

### Errors

Every error has the same envelope:

```json
{"error": {"code": "validation", "message": "Рачун мора имати бар један артикал (items)."}}
```

| Code | HTTP | Meaning |
| --- | --- | --- |
| `unauthorized` | 401 | Missing, malformed, invalid or revoked key |
| `rate_limited` | 429 | Over 240 requests per minute, or over 30 mails per hour |
| `bad_request` | 400 | The request body is not valid JSON |
| `validation` | 422 | The request is well formed but not acceptable |
| `not_found` | 404 | No such invoice or item, or an unknown API route |
| `pfr_error` | 502 | The PFR refused or was unreachable. **No invoice was issued** |
| `mail_error` | 502 | The receipt e-mail could not be sent |

Validation messages that come from the invoice validator are in Serbian Cyrillic, because they are
the same messages the cashier sees on screen. Messages raised by the API layer itself are English.

### Enums

- `invoiceType`: `Normal`, `ProForma`, `Copy`, `Training`, `Advance`.
  `Normal` (промет) and `Advance` (аванс) are fiscal receipts. `ProForma` (предрачун), `Copy`
  (копија) and `Training` (обука) print **ОВО НИЈЕ ФИСКАЛНИ РАЧУН** and never enter turnover.
- `transactionType`: `Sale`, `Refund`.
- `paymentType`: `Cash`, `Card`, `Check`, `WireTransfer`, `Voucher`, `MobileMoney`, `Other`.
- Tax labels are single Cyrillic letters configured per installation. The defaults are `Ђ` (О-ПДВ,
  20%), `Е` (П-ПДВ, 10%), `Г` (Без ПДВ, 0%) and `А` (Није у ПДВ). Always read them from
  `GET /tax-labels` rather than hard coding them, and never compute tax yourself: the rate that
  gets printed comes from the PFR.

---

## Endpoints

18 operations across 14 paths.

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/status` | ESIR and PFR status of the installation |
| GET | `/me` | The calling key: label, dates, limit, permissions |
| GET | `/tax-labels` | Tax labels and rates in force |
| GET | `/categories` | Distinct categories of active items |
| POST | `/invoices` | Issue an invoice |
| GET | `/invoices` | List invoices |
| GET | `/invoices/{uuid}` | One invoice |
| GET | `/invoices/{uuid}/journal` | The receipt text, `text/plain` |
| POST | `/invoices/{uuid}/refund` | Full refund of that invoice |
| POST | `/invoices/{uuid}/copy` | Copy of that invoice |
| POST | `/invoices/{uuid}/email` | E-mail the receipt to a buyer |
| GET | `/reports/summary` | Turnover for a period, with breakdowns |
| GET | `/reports/daily` | Per-day turnover and invoice count |
| GET | `/items` | List items |
| POST | `/items` | Create an item |
| PUT | `/items/{id}` | Update an item |
| PATCH | `/items/{id}` | Update an item (identical to PUT) |
| DELETE | `/items/{id}` | Deactivate an item |

---

### GET /status

ESIR and PFR status of this installation. No parameters.

```json
{
  "app": "Otkucaj",
  "version": "1.2.0",
  "esirNumber": "ДЕМО/1.0",
  "pfr": {"ok": true, "mode": "demo", "uid": "DM9EWQ0Z", "message": "Демо ПФР симулатор: активан"},
  "time": "2026-08-20T12:34:56.789+02:00"
}
```

`pfr.mode` is `demo`, `lpfr` or `vpfr`. For a real processor the fields of its own status response
are merged in, and `ok` is `false` with a `message` when it could not be reached.

Errors: `unauthorized` 401, `rate_limited` 429.

---

### GET /me

Information about the calling key. No parameters.

```json
{
  "key": {
    "id": 3,
    "label": "Веб продавница",
    "prefix": "kasir_9f21a…",
    "active": true,
    "createdAt": "2026-08-14 09:12:03",
    "lastUsedAt": "2026-08-20 11:58:41"
  },
  "rateLimit": {"requests": 240, "windowSeconds": 60},
  "scope": "full",
  "permissions": [
    "invoices:read", "invoices:write", "items:read", "items:write",
    "reports:read", "taxLabels:read", "categories:read"
  ],
  "note": "API keys are not scoped individually: every active key may call every route."
}
```

Only the stored prefix is echoed, never the key. `lastUsedAt` is the **previous** call, because the
current one is stamped after the key row is read. `scope` is always `full` and `permissions` lists
what every active key really may do: there is no per-key scoping in the key store, and this
endpoint does not pretend otherwise.

Errors: `unauthorized` 401, `rate_limited` 429.

---

### GET /tax-labels

Active tax labels only, from the same source the invoice validator uses. A label returned here is a
label `POST /invoices` will accept.

```json
{
  "taxLabels": [
    {"label": "Ђ", "name": "О-ПДВ", "rate": 20.0},
    {"label": "Е", "name": "П-ПДВ", "rate": 10.0},
    {"label": "Г", "name": "Без ПДВ", "rate": 0.0},
    {"label": "А", "name": "Није у ПДВ", "rate": 0.0}
  ]
}
```

Errors: `unauthorized` 401, `rate_limited` 429.

---

### GET /categories

Distinct non-empty categories of active items, alphabetically. Categories are assigned to items in
the application or through its CSV import; the item routes of this API neither read nor write the
category field.

```json
{"categories": ["Пиће", "Слаткиши", "Храна"]}
```

Errors: `unauthorized` 401, `rate_limited` 429.

---

### POST /invoices

Issue an invoice. Validates, sends to the configured PFR, stores the result.

**Body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `invoiceType` | string | no | Default `Normal` |
| `transactionType` | string | no | Default `Sale` |
| `items` | array | **yes** | 1 to 500 lines |
| `items[].name` | string | **yes** | Up to 200 characters |
| `items[].quantity` | number | **yes** | Greater than 0, up to 999999, 3 decimals |
| `items[].unitPrice` | number | **yes** | 0 or more, VAT included, 2 decimals |
| `items[].label` | string | **yes** | Tax label. `labels: ["Ђ"]` is accepted instead |
| `items[].unit` | string | no | Default `ком` |
| `items[].gtin` | string | no | 8 to 14 digits |
| `items[].itemId` | integer | no | Optional link to a catalogue item |
| `payments` | array | no | Omit and the whole amount is booked as `Cash` |
| `cashier` | string | no | Printed as Касир. Defaults to `API` |
| `buyerId` | string | no | Up to 64 chars, for example `10:123456789`. Mandatory on refunds |
| `buyerCostCenterId` | string | no | Optional buyer field |
| `referentDocumentNumber` | string | no | Mandatory for `Refund` and for `Copy` |
| `referentDocumentDT` | string | no | Time of the original document |
| `dateAndTimeOfIssue` | string | no | ESIR time for a payment received earlier |
| `adText` | string | no | Up to 500 chars, printed under the receipt |

**Payment rules.** One payment entry is stretched to the invoice total. Two or more entries must sum
to the total, with a tolerance of 0.011. Entries of 0 or less are dropped. When an installation runs
in the simplified cash mode of article 6 paragraph 2 of the Pravilnik, the types `Card`, `Check` and
`MobileMoney` are refused and must be entered as `Cash`.

**Refund rules.** A `Refund` needs `referentDocumentNumber` and `buyerId`. To void your own receipt,
enter your own PIB as `10:<PIB>`.

**Response** `201`, the invoice:

```json
{
  "uuid": "9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30",
  "invoiceType": "Normal",
  "transactionType": "Sale",
  "cashier": "Касир 1",
  "total": 480.0,
  "payments": [{"paymentType": "Cash", "amount": 480.0}],
  "items": [
    {"name": "Кафа еспресо", "unit": "ком", "quantity": 2.0, "unitPrice": 180.0,
     "totalAmount": 360.0, "label": "Ђ", "gtin": null}
  ],
  "taxItems": [
    {"label": "Ђ", "categoryName": "О-ПДВ", "rate": 20.0, "amount": 60.0, "total": 360.0}
  ],
  "buyerId": null,
  "referentDocumentNumber": null,
  "referentDocumentDT": null,
  "pfr": {
    "mode": "demo",
    "demo": true,
    "requestedBy": "DM9EWQ0Z",
    "signedBy": "DM9EWQ0Z",
    "invoiceNumber": "DM9EWQ0Z-DM9EWQ0Z-42",
    "invoiceCounter": "42/97ПП",
    "sdcDateTime": "2026-08-20 12:34:56",
    "verificationUrl": "https://otkucaj.com/verify?id=9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30"
  },
  "journal": "============ ФИСКАЛНИ РАЧУН ============\n...",
  "createdAt": "2026-08-20 12:34:56"
}
```

The same shape comes back from the refund and copy routes, from `GET /invoices/{uuid}` and inside
`GET /invoices`.

Errors: `validation` 422, `pfr_error` 502, `bad_request` 400, `unauthorized` 401,
`rate_limited` 429.

---

### GET /invoices

List invoices, newest first, 100 per page, each in the full shape above.

| Parameter | Notes |
| --- | --- |
| `from` | Matched as `from 00:00:00` against the creation time |
| `to` | Matched as `to 23:59:59` |
| `type` | One of the invoice types. Any other value is ignored, not refused |
| `page` | 1 based, 100 per page |

`from` and `to` are **not** format checked on this route (unlike the report routes): a value that is
not a date simply matches nothing. There is no total count in the answer, so keep requesting pages
until fewer than 100 invoices come back.

```json
{"page": 1, "perPage": 100, "invoices": [ /* ... */ ]}
```

Errors: `unauthorized` 401, `rate_limited` 429.

---

### GET /invoices/{uuid}

One invoice, in the shape shown under `POST /invoices`.

Errors: `not_found` 404, `unauthorized` 401, `rate_limited` 429.

---

### GET /invoices/{uuid}/journal

The stored journal as `text/plain; charset=utf-8`: the 40 column Cyrillic receipt exactly as it is
printed. The only route that does not answer with JSON. Errors are still JSON.

```
============ ФИСКАЛНИ РАЧУН ============
             ПИБ: 123456789
          Откуцај · демо радња
            Продајно место 1
                Београд
Касир:                           Касир 1
ЕСИР број:                      ДЕМО/1.0
```

Errors: `not_found` 404, `unauthorized` 401, `rate_limited` 429.

---

### POST /invoices/{uuid}/refund

Issue a **full** refund of an existing invoice. The new invoice carries the referent number and
referent time of the original, keeps its invoice type (`Advance` stays `Advance`, anything else
becomes `Normal`), and reuses the items and payments of the original. This mirrors the Рефундација
flow of the web application.

Only an **issued** `Normal` or `Advance` invoice whose transaction type is `Sale` can be refunded.

**Body** (all optional):

| Field | Notes |
| --- | --- |
| `buyerId` | Defaults to the buyer of the original. Refunds require one |
| `buyerCostCenterId` | Defaults to the value on the original |
| `payments` | Defaults to the payments of the original. Must sum to the original total |
| `cashier` | Printed as Касир |
| `adText` | Up to 500 chars |

```bash
curl -X POST https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/refund \
  -H "Authorization: Bearer kasir_VAS_API_KLJUC" \
  -H "Content-Type: application/json" \
  -d '{"buyerId": "10:123456789"}'
```

If neither the body nor the original carries a buyer identification, the answer is `validation` 422
with the message the application itself shows, which tells the seller to enter their own PIB.

**For a partial refund do not use this route.** Post to `/invoices` with `transactionType` set to
`Refund`, only the lines you are refunding, and the referent number and time of the original.

Response `201`, the new refund invoice. Errors: `validation` 422, `not_found` 404, `pfr_error` 502,
`bad_request` 400, `unauthorized` 401, `rate_limited` 429.

---

### POST /invoices/{uuid}/copy

Issue a `Copy` of an existing invoice, through the same code path as the Копија button in the web
application. Same items and payments, referent number and time of the original, and the receipt
prints ОВО НИЈЕ ФИСКАЛНИ РАЧУН. **No request body.**

Only an issued invoice that is not itself a copy can be copied.

Response `201`, the new copy. Errors: `validation` 422 (copy of a copy, or not issued),
`not_found` 404, `pfr_error` 502, `unauthorized` 401, `rate_limited` 429.

---

### POST /invoices/{uuid}/email

Send the receipt (journal plus verification link) to one address, using the same code the web
application uses, and writing the same `invoice.email` audit entry. A receipt issued in demo mode
carries a plain note saying it was issued in a demo environment and is not fiscalized.

```json
{"to": "kupac@example.com"}
```

Response:

```json
{"ok": true, "uuid": "9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30", "to": "kupac@example.com"}
```

`ok: true` means the message was accepted by the mail system, not that it reached the inbox.

Limit: 30 messages per hour per key, on top of the 240 requests per minute.

Errors: `validation` 422 (bad or overlong address), `not_found` 404, `rate_limited` 429,
`mail_error` 502, `bad_request` 400, `unauthorized` 401.

---

### GET /reports/summary

Turnover for a period with breakdowns by tax label, payment type and cashier, computed exactly as
the Извештаји screen computes it.

Parameters: `from` and `to`, both `YYYY-MM-DD`, both defaulting to today. `from` must not be later
than `to`.

**Accounting rule, returned in every report as `rule`:** only issued `Normal` and `Advance` invoices
count, refunds subtract from turnover, and `ProForma`, `Copy` and `Training` never enter a total.

```json
{
  "from": "2026-08-01",
  "to": "2026-08-20",
  "totals": {
    "invoices": 214,
    "turnover": 486320.50,
    "refunds": {"count": 3, "amount": 2140.00}
  },
  "byTaxLabel": [
    {"label": "Ђ", "name": "О-ПДВ", "rate": 20.0, "turnover": 401220.50},
    {"label": "Е", "name": "П-ПДВ", "rate": 10.0, "turnover": 85100.00}
  ],
  "byPaymentType": [
    {"paymentType": "Cash", "amount": 302150.50},
    {"paymentType": "Card", "amount": 184170.00}
  ],
  "byCashier": [
    {"cashier": "Касир 1", "invoices": 141, "turnover": 320410.50}
  ],
  "rule": "Turnover counts issued Normal and Advance receipts only; refunds subtract; ProForma, Copy and Training are excluded."
}
```

`totals.refunds.amount` is a positive number and is informational: it is already subtracted from
`totals.turnover`. In `byTaxLabel`, `name` and `rate` are `null` when a label used on old invoices
is no longer defined.

Errors: `validation` 422 (date not `YYYY-MM-DD`, or `from` later than `to`), `unauthorized` 401,
`rate_limited` 429.

---

### GET /reports/daily

Per-day turnover and invoice count for a period, same accounting rule and same grouping query as the
daily table of the Извештаји screen. Parameters `from` and `to`, as above.

```json
{
  "from": "2026-08-18",
  "to": "2026-08-20",
  "days": [
    {"date": "2026-08-18", "invoices": 41, "turnover": 92310.00},
    {"date": "2026-08-20", "invoices": 12, "turnover": 24880.50}
  ],
  "rule": "Turnover counts issued Normal and Advance receipts only; refunds subtract; ProForma, Copy and Training are excluded."
}
```

Days with no traffic are omitted rather than returned as zero rows.

Errors: `validation` 422, `unauthorized` 401, `rate_limited` 429.

---

### GET /items

The item catalogue sorted by name, at most 1000 rows, no filters and no pagination. Inactive items
are included and carry `active: false`. The `category` of an item is not exposed here.

```json
{
  "items": [
    {"id": 12, "plu": "1001", "name": "Кафа еспресо", "unit": "ком",
     "price": 180.0, "label": "Ђ", "gtin": "8600123456789", "active": true}
  ]
}
```

Errors: `unauthorized` 401, `rate_limited` 429.

---

### POST /items

Create an active item.

| Field | Required | Notes |
| --- | --- | --- |
| `name` | **yes** | Up to 200 characters |
| `label` | **yes** | Must be an active tax label |
| `price` | no | Default 0, must be 0 or more, rounded to 2 decimals |
| `unit` | no | Default `ком` |
| `plu` | no | Stored as sent, uniqueness is not checked on this route |
| `gtin` | no | 8 to 14 digits |

Response `201`: `{"id": 12, "ok": true}`.

Errors: `validation` 422, `bad_request` 400, `unauthorized` 401, `rate_limited` 429.

---

### PUT /items/{id} and PATCH /items/{id}

The same handler: a partial update where every absent field keeps its stored value. Accepts `plu`,
`name`, `unit`, `price`, `label`, `gtin` and `active`.

Unlike `POST /items`, this route does **not** re-validate the tax label, the price or the GTIN: what
you send is what gets stored. Send `label` values that `GET /tax-labels` returns.

Response `200`: `{"ok": true}`. Errors: `not_found` 404, `bad_request` 400, `unauthorized` 401,
`rate_limited` 429.

---

### DELETE /items/{id}

Sets `active` to false. Items are never physically deleted, because issued invoices reference them.
The answer is `{"ok": true}` even when no item with that id exists: this route does not check first.

Errors: `unauthorized` 401, `rate_limited` 429.

---

## Integration notes

- **A 502 `pfr_error` means nothing was issued.** It is safe to retry the same request. A 201 that
  you failed to read is not safe to retry: look the invoice up with `GET /invoices` first.
- **Do not compute tax.** Send prices with VAT included and a tax label. `taxItems` on the answer is
  what the PFR calculated and what the receipt prints.
- **Store the `uuid`** returned with each invoice. It is the key for the journal, refund, copy and
  e-mail routes.
- **Print or forward `journal` as it is.** It is the receipt of record, in a fixed 40 column layout.
- **Read `pfr.demo`.** While it is `true`, the receipt is a demo document with no fiscal validity,
  and your system should not present it to a customer as a fiscal receipt.
- A ready-made WordPress plugin for WooCommerce shops is available at
  <https://otkucaj.com/preuzimanja/otkucaj-fiskalizacija.zip>. It writes the PFR number and the
  verification link into each order.

## More

- Product page: <https://otkucaj.com/>
- User manual, Serbian Cyrillic: <https://otkucaj.com/uputstvo>
- API documentation page, Serbian Cyrillic: <https://otkucaj.com/api-docs>
- OpenAPI 3.1 document: <https://otkucaj.com/openapi.json>
- Terms of use: <https://otkucaj.com/uslovi>
- Privacy policy: <https://otkucaj.com/politika-privatnosti>
- Contact: filip@tefis.io
