Откуцај
SRENРУ Log in

Otkucaj API v1

A REST API for issuing fiscal receipts, refunds and copies, e-mailing receipts, turnover reports and working with items. Everything the browser till can do, your software can do too: an online shop, an ERP, a point-of-sale system.

DEMO MODE JSON · UTF-8 240 requests/min

Everything this document says about the regulations is our understanding, not legal or tax advice; check with your accountant or the Tax Administration before you act.

A note on language. This reference is in English. The receipt itself, its journal and the tax labels are always in Serbian, which the regulations require in Cyrillic and/or Latin script; Otkucaj returns them in Cyrillic, as the PFR produces them, so they appear here in that form. Item names and the cashier name are free text and may be in any language.

For AI agents and language models

Machine-readable descriptions of this API exist and are maintained alongside this page.

Introduction and base URL

The base URL of every call:

https://otkucaj.com/api/v1

Every request and response is JSON encoded in UTF-8, except the journal, which is returned as plain text (text/plain). Communication goes over HTTPS only. Amounts in JSON use a dot as the decimal separator (2400.00), because that is the JSON standard; on the receipt itself and in the journal, amounts are in Serbian format (2.400,00).

Versioning. The API version is part of the path: /api/v1. Existing fields and behaviour do not change in a backwards-incompatible way within v1; new capabilities are added as new, optional fields. If the contract ever has to break, that will live at /api/v2 and v1 will keep working.

A trailing slash is ignored: /api/v1/items/ and /api/v1/items are the same path. An unknown path returns 404 with the code not_found.

Otkucaj is an approved ESIR, classification 3, in two approved versions: record number 1651, version 1.3.5 (approved 2026-08-31) and record number 1667, version 1.3.6 (approved 2026-09-08, the receipt as a document on A4 and A5). New accounts issue under 1667/1.3.6. A new account still starts in the test environment with the demo simulator, where every receipt carries the notice ОВО НИЈЕ ФИСКАЛНИ РАЧУН, numbers from the demo counter, and a verification link on this site rather than on suf.purs.gov.rs. You can develop and test your integration right now: moving to an L-PFR or a V-PFR changes nothing in the API contract, only the receipts become fiscal.

Quick start

From a key to the first receipt in three steps.

  1. Check the connection and the state of the PFR.
    curl -H "Authorization: Bearer kasir_your_key" \
      https://otkucaj.com/api/v1/status
  2. Issue the first receipt.
    curl -X POST https://otkucaj.com/api/v1/invoices \
      -H "Authorization: Bearer kasir_your_key" \
      -H "Content-Type: application/json" \
      -d '{
        "invoiceType": "Normal",
        "transactionType": "Sale",
        "cashier": "Web shop",
        "payments": [{ "paymentType": "Card", "amount": 2400.00 }],
        "items": [
          { "name": "T-shirt", "quantity": 2, "unitPrice": 1200.00, "label": "Ђ" }
        ]
      }'

    The answer is 201 with the whole invoice: keep the uuid and pfr.invoiceNumber. The full response shape is under POST /invoices.

  3. Fetch the journal (the receipt text, ready to print at 40 columns):
    curl -H "Authorization: Bearer kasir_your_key" \
      https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/journal
GET

/api/v1/status

The state of the application and of the connection to the PFR. Use it to check the configuration and for monitoring.

Authentication
Authorization: Bearer <api-key>
Parameters
none
Response
200, JSON

Request

curl -H "Authorization: Bearer kasir_your_key" \
  https://otkucaj.com/api/v1/status

Response

{
  "app": "Otkucaj",
  "version": "1.3.6",
  "esirNumber": "1667/1.3.6",
  "environment": "sandbox",
  "fiscal": false,
  "ready": true,
  "problems": [],
  "pfr": {
    "ok": true,
    "mode": "demo",
    "env": "sandbox",
    "uid": "DMX7K2QP",
    "message": "Демо ПФР симулатор: активан"
  },
  "company": { "name": "…", "tin": "…", "location": "…" },
  "time": "2026-08-20T14:00:00.000+02:00"
}

pfr.mode is demo, lpfr or vpfr, depending on how the installation is configured. In L-PFR or V-PFR mode the pfr object also carries whatever the fiscal invoice processor itself returned; ok: false with a message means the PFR is currently unreachable. esirNumber is assembled from the ESIR number and version in Settings. environment and fiscal are explained under Environments.

  • 401 unauthorized
  • 429 rate_limited

Authentication

Every request must carry the header:

Authorization: Bearer <api-key>

Keys are generated by an administrator in the application, under Settings · API keys. A key is shown only once, at the moment it is generated: only its SHA-256 hash is stored in the database, so a lost key cannot be read again; it is revoked and a new one generated. Revocation is immediate: the next request with a revoked key gets 401.

Keys are not individually scoped: every active key may call every path. If you want to separate systems, generate a separate key per system and revoke it individually when needed; that is the level of isolation the application genuinely offers.

Keep the key on your server only. The key must never appear in JavaScript running in a customer's browser, in a mobile app, in a public repository or in URL parameters. Anyone who sees the key can issue receipts in your name. Always send calls to Otkucaj from your own server.
GET

/api/v1/me

Details of the key the request was signed with: label, stored prefix, dates, the rate limit actually applied, and what the key may do. Useful for checking that an integration is using the key you think it is.

Authentication
Authorization: Bearer <api-key>
Parameters
none
Response
200, JSON

Request

curl -H "Authorization: Bearer kasir_your_key" \
  https://otkucaj.com/api/v1/me

Response

{
  "key": {
    "id": 3,
    "label": "Web shop",
    "prefix": "kasir_7f3a",
    "active": true,
    "createdAt": "2026-08-01 09:12:44",
    "lastUsedAt": "2026-08-19 13:58:02"
  },
  "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."
}
Three honest notes about this response.
prefix is the short string you also see in Settings; the key itself is never stored in readable form and is never returned.
scope is always "full", and permissions lists what every active key can actually do. It is not configurable per key: there is no per-key permission system, and this response does not pretend otherwise.
lastUsedAt is the previous call, not this one: the time of the current call is written after the key's row has been read.
  • 401 unauthorized
  • 429 rate_limited

Limits and behaviour

LimitValueWhat happens when exceeded
Requests per key240 in 60 seconds429 rate_limited
E-mailed receipts per key30 messages an hour429 rate_limited, with its own message
Lines per invoice1 to 500422 validation
Invoices per page (GET /invoices)100next page through page
Items per call (GET /items)1,000the excess is not returned; there is no paging
Length of a line and item name200 characters422 validation
Length of buyerId20 characters, form code:value422 validation
Length of adText500 characters422 validation

The limit window is fixed, not sliding: the first request opens a 60-second window and counts to 240; when the window expires the counter starts again. Exceeding it returns HTTP 429:

{ "error": { "code": "rate_limited", "message": "Rate limit exceeded (240 requests/minute)." } }

When you get a 429, wait until the current window expires and carry on. For ordinary shop traffic (one receipt per order) this limit is not reached in practice. The limit that is applied and the one GET /me reports come from the same place in the code, so they cannot drift apart.

Environments: test and production

Every Otkucaj installation has two separate environments, and one of them is active at any moment. The API always works in the active one, and which that is can be seen in the GET /status response and in every list and report.

EnvironmentWhat it isAre the receipts fiscal
sandboxThe Tax Administration's test system (*.sandbox.suf.purs.gov.rs). Used for the technical review before approval and for training.No. They do not count as turnover and do not enter the books.
productionThe live Tax Administration system.Yes. Every Normal and Advance receipt is a fiscal receipt; Copy, Pro-forma and Training are not.

The two environments have a different security element, a different PAC and a different ESIR number. The application refuses to install the same certificate in both, and refuses to point production at a Tax Administration test address (or the reverse).

The data is completely separated. An invoice records the environment it was issued in, so GET /invoices, GET /reports/summary and GET /reports/daily return only what belongs to the active environment. A test receipt cannot enter production turnover.

GET /api/v1/status

{
  "app": "Otkucaj",
  "version": "1.3.6",
  "esirNumber": "1667/1.3.6",
  "environment": "sandbox",     // sandbox | production
  "fiscal": false,              // true only in production with a real PFR
  "ready": true,                // whether a receipt can be issued at all
  "problems": [],               // if not ready, the reasons in order
  "pfr": { "ok": true, "mode": "demo", "env": "sandbox" },
  "company": { "name": "…", "tin": "…", "location": "…" },
  "time": "2026-08-20T14:00:00.000+02:00"
}

Before you start sending real receipts, check environment and fiscal. If fiscal is false, what you are issuing is not a fiscal receipt, whatever the journal says.

Every issued invoice carries the same information:

"pfr": {
  "mode": "vpfr",
  "environment": "production",
  "fiscal": true,
  "invoiceNumber": "…",
  "verificationUrl": "https://suf.purs.gov.rs/v/?vl=…",
  "verificationQRCode": "R0lGODlh…"   // base64 GIF drawn by the PFR itself
}

When verificationQRCode is present, that is the QR belonging to the receipt: the PFR draws it, to the Tax Administration's parameters. Do not draw your own over it. In demo mode the field is absent and the QR is built from verificationUrl.

Idempotency: how not to issue the same receipt twice

If the connection breaks after the request arrived but before the answer got back, it looks exactly like a request that never arrived at all. Retrying then creates two fiscal receipts for one sale, which cannot be deleted, only refunded.

So every POST /invoices may carry a request key. Send it one of two ways:

POST /api/v1/invoices
Authorization: Bearer <api-key>
Idempotency-Key: order-2026-08-20-4711
Content-Type: application/json

{ "items": [ … ] }

or in the body itself:

{ "clientRequestId": "order-2026-08-20-4711", "items": [ … ] }

The rules are simple:

  • The key may be 8 to 64 characters: letters, digits and . _ : -. Anything else returns 422.
  • The key is reserved before the PFR is called, so two simultaneous retries cannot both get through.
  • A retry with the same key returns 201 and the same invoice, with the same uuid and the same PFR number. No new one is issued.
  • If the PFR refuses the request, the key is released, so a genuine retry works.

The best choice is something you already have and that is unique, for example the order number from your own system. That way both a network interruption and your own retry lead to the same receipt.

Calling from a browser (CORS)

The API allows calls from any origin (Access-Control-Allow-Origin: *) and answers the OPTIONS preflight. Authentication is the Authorization header only, never a cookie, and Access-Control-Allow-Credentials is deliberately not sent.

That means a third-party site without your key can do nothing. But it also means the key must not sit in the JavaScript of a public page, where it is visible to everyone. From the browser call your own server, and let your server call Otkucaj.

Invoices

Seven paths around invoices: issuing, listing, detail, journal, refund, copy and e-mail.

POST

/api/v1/invoices

Issue an invoice. The request is validated, forwarded to the PFR, and the invoice is written to the database only once the PFR has signed it. A 201 response returns the whole invoice, including the journal and the verification link.

Authentication
Authorization: Bearer <api-key>
Body
JSON, Content-Type: application/json
Response
201, the whole invoice
FieldTypeRequiredDescription
invoiceTypestringno (default Normal)Normal, Advance, ProForma, Training, Copy
transactionTypestringno (default Sale)Sale or Refund
items[]arrayyes1 to 500 lines; the total must be greater than 0
items[].namestringyesLine name, up to 200 characters
items[].unitstringnoUnit of measure, default ком
items[].quantitynumberyesGreater than 0 and at most 999,999; rounded to 3 decimals (for example 0.375)
items[].unitPricenumberyesUnit price including VAT, 0 or more, rounded to 2 decimals
items[].totalAmountnumbernoLine total. The server calculates it itself as quantity × price; the value you send is not used
items[].labelstringyesA tax label from the code list in force, see GET /tax-labels. Rates are determined by the PFR alone
items[].gtinstringnoGTIN or barcode, 8 to 14 digits
items[].itemIdnumbernoThe id of an item in the catalogue. It is only remembered alongside the invoice line as a link to the item; the price and name always come from the request itself
payments[]arraynoPayment methods. If omitted or empty, the whole amount goes as Cash
payments[].paymentTypestringyesCash, Card, Check, WireTransfer, Voucher, MobileMoney (instant payment), Other
payments[].amountnumberyesAmount. Entries of 0 or less are discarded. If exactly one payment remains after that, its amount is set to the invoice total; if there are several, their sum must match the sum of the lines (tolerance 0.01)
cashierstringnoThe cashier name on the receipt, at most 50 characters, the PFR's limit: a longer value is refused (422) and nothing is shortened; for API calls the default is API
buyerIdstringconditionalBuyer identification, up to 20 characters, in the form code:value. Code list: 10 TIN, 11 personal number, 12 TIN:JBKJS, 20 ID card, 23 passport, 30 foreign passport. Mandatory on every refund
buyerCostCenterIdstringnoThe buyer's optional field (cost centre)
referentDocumentNumberstringconditionalThe PFR number of the original invoice. Mandatory for Refund and for Copy
referentDocumentDTstringnoThe time of the original invoice, YYYY-MM-DD HH:MM:SS
dateAndTimeOfIssuestringnoESIR time, YYYY-MM-DD HH:MM:SS. Used for an advance where the payment arrived earlier than the receipt is issued (for example a transfer posted the next day); printed on the receipt as "ЕСИР време". The Advance Sale receipt is then issued no later than the next working day after the payment is received (art. 11 para. 5 of the Rulebook on types of fiscal receipts)
adTextstringnoAdvertising text, up to 500 characters, printed below the end of the receipt. When closing an advance, the number and date of the last advance receipt go here
clientRequestIdstringnoIdempotency key, 8 to 64 characters of A-Za-z0-9._:-. The same effect as the Idempotency-Key header, see Idempotency

Request

curl -X POST https://otkucaj.com/api/v1/invoices \
  -H "Authorization: Bearer kasir_your_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-2026-08-20-4711" \
  -d '{
    "invoiceType": "Normal",
    "transactionType": "Sale",
    "cashier": "Web shop",
    "payments": [{ "paymentType": "Card", "amount": 2400.00 }],
    "items": [
      { "name": "T-shirt", "unit": "ком", "quantity": 2, "unitPrice": 1200.00,
        "label": "Ђ", "gtin": "8600000000000" }
    ]
  }'

Response 201

{
  "uuid": "9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30",
  "invoiceType": "Normal",
  "transactionType": "Sale",
  "cashier": "Web shop",
  "total": 2400.0,
  "payments": [
    { "paymentType": "Card", "amount": 2400.0 }
  ],
  "items": [
    {
      "name": "T-shirt",
      "unit": "ком",
      "quantity": 2.0,
      "unitPrice": 1200.0,
      "totalAmount": 2400.0,
      "label": "Ђ",
      "gtin": "8600000000000"
    }
  ],
  "taxItems": [
    { "label": "Ђ", "categoryName": "О-ПДВ", "rate": 20.0, "amount": 400.0, "total": 2400.0 }
  ],
  "buyerId": null,
  "referentDocumentNumber": null,
  "referentDocumentDT": null,
  "pfr": {
    "mode": "demo",
    "environment": "sandbox",
    "fiscal": false,
    "demo": true,
    "requestedBy": "DMX7K2QP",
    "signedBy": "DMX7K2QP",
    "invoiceNumber": "DMX7K2QP-DMX7K2QP-302",
    "invoiceCounter": "143/302ПП",
    "sdcDateTime": "2026-08-19 14:02:33",
    "verificationUrl": "https://otkucaj.com/verify?id=9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30",
    "verificationQRCode": null
  },
  "clientRequestId": "order-2026-08-20-4711",
  "journal": "======== ФИСКАЛНИ РАЧУН =======\n...",
  "createdAt": "2026-08-19 14:02:33"
}

The same shape is returned by GET /invoices/{uuid}, by every element of the invoice list, and by the refund and copy paths. What the PFR fields mean: invoiceNumber is the PFR receipt number (form JID-JID-N), invoiceCounter is the counter (for example 143/302ПП: the 143rd Normal-Sale receipt out of 302 in total), sdcDateTime is the PFR time, verificationUrl is the link to the verification page, and demo says whether the receipt was issued in demo mode. taxItems is the tax summary by label, with the rate, the tax amount and the base; the rates come from the PFR alone, never from your request.

  • 400 bad_request
  • 401 unauthorized
  • 422 validation
  • 429 rate_limited
  • 409 pfr_error

On a 409 the invoice was not issued and nothing was written to the database.

GET

/api/v1/invoices

A list of invoices, newest first, up to 100 per page, from the environment currently in use. Every element is a whole invoice, in the same shape as the response to POST /invoices, journal included.

Authentication
Authorization: Bearer <api-key>
Parameters
in the query string
Response
200, JSON
ParameterTypeDescription
fromstringDate from, YYYY-MM-DD (includes the whole day from 00:00:00)
tostringDate to, YYYY-MM-DD (includes the whole day to 23:59:59)
typestringInvoice type: Normal, Advance, ProForma, Training, Copy
pagenumberPage, from 1 (default 1), 100 invoices each, at most 10000
How the parameters behave. A malformed date returns 422, the same as in the reports. An unknown value in type is silently ignored and the filter is not applied. Send dates as YYYY-MM-DD and a type from the list above.

Request

curl -H "Authorization: Bearer kasir_your_key" \
  "https://otkucaj.com/api/v1/invoices?from=2026-08-01&to=2026-08-19&type=Normal&page=1"

Response

{
  "page": 1,
  "perPage": 100,
  "environment": "sandbox",
  "invoices": [
    { "uuid": "9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30", "invoiceType": "Normal", "...": "..." }
  ]
}

An empty list means there are no invoices for the given filters. The total count is not returned: page through until you get fewer than 100 elements.

  • 401 unauthorized
  • 422 validation
  • 429 rate_limited
GET

/api/v1/invoices/{uuid}

One invoice by UUID, in the full shape described under POST /invoices.

Authentication
Authorization: Bearer <api-key>
Path
uuid: 36 characters, lowercase letters and digits with hyphens, exactly as the API returned it
Response
200, the whole invoice

Request

curl -H "Authorization: Bearer kasir_your_key" \
  https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30

Response

{
  "uuid": "9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30",
  "invoiceType": "Normal",
  "transactionType": "Sale",
  "total": 2400.0,
  "pfr": { "invoiceNumber": "DMX7K2QP-DMX7K2QP-302", "...": "..." },
  "journal": "======== ФИСКАЛНИ РАЧУН =======\n...",
  "createdAt": "2026-08-19 14:02:33"
}

A UUID that does not exist returns 404 with the code not_found. A UUID written in uppercase does not match the path pattern and ends up as an unknown path, also 404: send it exactly as the API gave it to you.

  • 401 unauthorized
  • 404 not_found
  • 429 rate_limited
GET

/api/v1/invoices/{uuid}/journal

The receipt journal as plain text (text/plain; charset=utf-8), 40 columns, in Cyrillic, ready for a 58 or 80 mm thermal printer. Identical to the journal the application prints itself. This is the only response that is not JSON.

Authentication
Authorization: Bearer <api-key>
Parameters
none
Response
200, text/plain; charset=utf-8

Request

curl -H "Authorization: Bearer kasir_your_key" \
  https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/journal

Response, abridged

======== ФИСКАЛНИ РАЧУН =======
              111111111
           Пример д.о.о.
           Продавница 1
       Булевар ослобођења 1
              Београд
Касир:                      Web shop
ЕСИР број:                    1667/1.3.6
------ ПРОМЕТ - ПРОДАЈА -------
Артикли
========================================
Назив   Цена         Кол.         Укупно
T-shirt /ком (Ђ)
        1.200,00     2          2.400,00
----------------------------------------
За уплату:                      2.400,00
Платна картица:                 2.400,00
========================================
Ознака  Име      Стопа          Порез
Ђ       О-ПДВ    20,00%         400,00
----------------------------------------
Укупан износ пореза:              400,00
========================================
ПФР време:         19.08.2026. 14:02:33
ПФР број рачуна:   DMX7K2QP-DMX7K2QP-302
Бројач рачуна:               143/302ПП
======== КРАЈ ФИСКАЛНОГ РАЧУНА ========

One receipt is one journal, and it prints on its own. Every correction to a receipt — a refund, a void, a copy — is a new fiscal document with its own number, its own journal and its own QR code. Never merge two journals into one record and never put a line of your own between them (no "===== VOID =====", no order number, no date): that puts two fiscal documents on one sheet with your text inside the frame, which, as we understand it, does not fit the rule that a receipt is issued in one copy (art. 13 para. 5 of the Rulebook), nor 16.П13, under which nothing below the end line is part of the receipt, and on A4 the receipt then breaks across pages. Keep each journal in its own field and print one document per sheet. The correcting receipt points back at the original through the Реф. број line the PFR writes itself.

A4 is not the same document as a till slip. Application ИБ 1651 (13.П3) approves both forms: the journal on A4, and the receipt as an A4 document with the seller block, an item table, the tax overview, the QR code and the fiscal slip verbatim. For an office printer take the second form — put the fiscal slip into the document unchanged, and the verification address as a real hyperlink outside the receipt frame. Better still, do not draw it at all: call GET /api/v1/invoices/{uuid}/document with your API key and print what comes back, character for character. That is the shape Otkucaj prints and the one that was approved, and it is the only way to obtain it over the API — the /racun/dokument page requires a sign-in and an API key will not open it. Add ?base=https://your-domain so the styles and fonts still resolve when you show the document on your own site, and &print=1 to open the print dialog on arrival.

If you render the receipt yourself from the journal field: the QR code goes inside the receipt frame — between the last ==== line and the КРАЈ ФИСКАЛНОГ РАЧУНА line, never below it. Anything printed below that line is not part of the fiscal receipt (Technical Guide 16.П13), so only the advertising field may sit there: not the QR, not the verification link, not your order number beside them. A printed QR must be a square of 40 to 50 mm (16.П12) and must not be shrunk with CSS, because scaling drops module columns and the code stops scanning. This is the same layout Otkucaj prints itself and the one approved under application ИБ 1651.

Non-fiscal types (Copy, Pro-forma, Training) carry the line "ОВО НИЈЕ ФИСКАЛНИ РАЧУН" instead of the fiscal receipt's header and footer, and the application doubles its font size when printing. A Copy-Refund additionally prints a "Потпис купца" space for the buyer's signature when money is returned.

  • 401 unauthorized
  • 404 not_found
  • 429 rate_limited
GET

/api/v1/invoices/{uuid}/document

The receipt as a finished printable document, exactly as the application prints it, so an integrator never draws a receipt of their own. paper selects the sheet: a4 (default) is the A4 document approved under application ИБ 1651 (13.П3): seller block, item table, tax overview, payment method, QR code and the fiscal slip verbatim; a5 is the fiscal slip on an A5 sheet (13.П4), the form a courier or fulfilment centre prints and packs with the parcel; a4slip is the fiscal slip on an A4 sheet. On every sheet the QR code sits inside the receipt frame, between the PFR block and the closing line, at 42 to 45 mm, and nothing of ours prints below „КРАЈ ФИСКАЛНОГ РАЧУНА“. With format=pdf the same sheet arrives as a PDF, rendered on the server by the same browser engine and the same @page rule (measured: the A5 page is 148 × 210 mm, the QR 45 mm and it decodes). GET /status reports in documents.pdf whether the installation can render PDF; if it cannot, ask for HTML and print it. Do not draw your own A4 or A5 - take this one. The response is not JSON.

Authentication
Authorization: Bearer <api-key>
Parameters
paper - a4 (default), a5 or a4slip. format - html (default) or pdf. base - your origin (e.g. https://shop.rs), so the styles and fonts resolve when you display the HTML document on your own site. print=1 - HTML opens the print dialog on arrival.
Response
200, text/html; charset=utf-8 or application/pdf

Request

curl -H "Authorization: Bearer kasir_your_key"   "https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/document?base=https://shop.rs&print=1"

# the slip on A5 as PDF, for the courier
curl -H "Authorization: Bearer kasir_your_key"   "https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/document?paper=a5&format=pdf" -o receipt.pdf

When a partner downloads the sheet while someone waits. A fulfilment centre that prints the receipt with the parcel opens the link with a packer standing over the box, so it has to be there in a second or two. Both POST /invoices and POST /invoices/{uuid}/close-advance therefore accept "prefetchDocument": "a5": Otkucaj answers immediately and renders that sheet as soon as the response is out, so a download arriving after it finds a finished file. Measured 02.09.2026: 0.60 s without the prefetch, 0.13 s with it. The sheet is then served from the cache, so every later download costs no more than the transfer itself.

The sheet itself is delimited by the <!--sheet--> and <!--/sheet--> comments if you need it without the rest of the page. Check that the response contains <!--sheet--> before you display it: that is how you tell the document from an error page.

  • 401 unauthorized
  • 404 not_found
  • 422 validation - paper, format or base not acceptable; configuration - this installation cannot render PDF (ask for HTML)
POST

/api/v1/invoices/{uuid}/refund

Refund an entire existing invoice in one call. The lines are taken from the original, the referent number and time point at the original, and the new invoice is issued through the same route as any other. This is what the Refund button in the application does.

Authentication
Authorization: Bearer <api-key>
Body
optional, JSON
Response
201, the new refund invoice
Conditions. Only an invoice that is Normal or Advance, of transaction type Sale and in the issued state can be refunded. A copy, a pro-forma, a training receipt and an already issued refund are refused with 422. These are the same conditions under which the button appears in the application.
Body fieldTypeDefaultDescription
buyerIdstringthe buyer from the originalBuyer identification. Mandatory for a refund: if it is neither in the body nor on the original, the answer is 422 with a message explaining that to void your own receipt you enter your own TIN
buyerCostCenterIdstringfrom the originalThe buyer's cost centre
paymentsarraythe payments from the originalHow the money is returned. The sum must match the invoice total
cashierstringAPIThe cashier name on the refund receipt, at most 50 characters
adTextstringemptyAdvertising text, up to 500 characters

The lines are always taken from the original invoice, in full, and cannot be set through the request body. For a partial refund (only some lines or a smaller quantity is returned) use POST /invoices with transactionType: "Refund", where you state exactly what is being returned.

Request

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

Response 201

{
  "uuid": "1c77a0d5-9b12-4f8e-b3aa-0d5e7c9f4411",
  "invoiceType": "Normal",
  "transactionType": "Refund",
  "cashier": "Web shop",
  "total": 2400.0,
  "buyerId": "10:123456789",
  "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-302",
  "referentDocumentDT": "2026-08-19 14:02:33",
  "pfr": {
    "invoiceNumber": "DMX7K2QP-DMX7K2QP-318",
    "invoiceCounter": "12/318РП",
    "...": "..."
  },
  "journal": "======== ФИСКАЛНИ РАЧУН =======\n...",
  "createdAt": "2026-08-19 16:41:02"
}
  • 400 bad_request
  • 401 unauthorized
  • 404 not_found
  • 422 validation
  • 429 rate_limited
  • 409 pfr_error
POST

/api/v1/invoices/{uuid}/close-advance

Close an advance in one call. For an issued advance sale (Аванс Продаја) the application issues, in order, the Аванс Рефундација on the whole advance (referent = the advance) and immediately after it the final Промет Продаја with the lines of the delivery, a reference to that refund and the advertising field, in which Техничко упутство §9.1.1 makes the number and date of the last advance receipt mandatory; the application adds the amount paid in advance and the number of the advance refund. This is what the „Затвори аванс“ button does in the application, end to end, in the shape of the approved samples П9, П10 and П15 of application ИБ 1651.

Authentication
Authorization: Bearer <api-key>
Body
items (required) - the lines of the delivery, as in POST /invoices. payments - defaults to the payments of the advance: the advanced amount goes under the payment method it was received with; send your own when part of the price is settled at delivery. buyerId - defaults to the buyer of the advance; the refund, which is never handed to the buyer, falls back to the seller's own PIB. adText - appended below the mandatory advance lines. cashier, buyerCostCenterId, dateAndTimeOfIssue, clientRequestId - as in POST /invoices; the request id applies to the final receipt and, with :ar appended, to the refund.
Response
201 when the final receipt was issued now, 200 when both documents already existed; either way advance, advanceRefund and invoice

Request

curl -X POST \
  https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/close-advance \
  -H "Authorization: Bearer kasir_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [{ "name": "Ормар по мери", "quantity": 1, "unitPrice": 8000.00, "label": "Ђ" }],
    "payments": [{ "paymentType": "WireTransfer", "amount": 8000.00 }],
    "adText": "Order #1042",
    "clientRequestId": "order-1042-final"
  }'

Safe to repeat. If the refund was issued and the final receipt was not (the PFR refused the second request, or the connection dropped between the two), a repeated call finds the refund by its referent number and issues only the final receipt. A refund once issued is never issued twice.

  • 401 unauthorized
  • 404 not_found
  • 422 validation - the invoice is not an issued advance sale, or the items and payments of the final receipt fail validation; the message names the document
  • 409 pfr_error - the PFR refused or was unreachable; the message names the document, and an already issued refund stays
POST

/api/v1/invoices/{uuid}/copy

Issue a copy of an existing invoice, through the same route the Copy button in the application uses. The copy carries all the original's lines and payments, the original's referent number and time, and its buyer identification.

Authentication
Authorization: Bearer <api-key>
Body
none, send an empty POST
Response
201, the new copy
Conditions. Only an issued invoice that is not itself a copy can be copied. A copy of a copy is refused with 422, exactly as in the application.

Request

curl -X POST \
  https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/copy \
  -H "Authorization: Bearer kasir_your_key"

Response 201

{
  "uuid": "6d4b1f92-2a70-4c31-8f5e-7b1c0a93de84",
  "invoiceType": "Copy",
  "transactionType": "Sale",
  "total": 2400.0,
  "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-302",
  "referentDocumentDT": "2026-08-19 14:02:33",
  "pfr": { "invoiceNumber": "DMX7K2QP-DMX7K2QP-319", "...": "..." },
  "journal": "ОВО НИЈЕ ФИСКАЛНИ РАЧУН\n...",
  "createdAt": "2026-08-19 16:43:20"
}

A copy is not a fiscal receipt: the journal has no fiscal receipt header, but the line "ОВО НИЈЕ ФИСКАЛНИ РАЧУН" instead. A copy of a refund additionally carries the space for the buyer's signature.

  • 401 unauthorized
  • 404 not_found
  • 422 validation
  • 429 rate_limited
  • 409 pfr_error
POST

/api/v1/invoices/{uuid}/email

E-mail the receipt to the buyer. The message contains the journal and the verification link, and is exactly the same as the one the button in the application sends, because both go through the same code. The send is written to the audit trail as invoice.email.

Authentication
Authorization: Bearer <api-key>
Body
JSON: {"to": "[email protected]"}
Response
200, confirmation
Body fieldTypeRequiredDescription
tostringyesThe recipient's address, a valid e-mail, at most 190 characters

Request

curl -X POST \
  https://otkucaj.com/api/v1/invoices/9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30/email \
  -H "Authorization: Bearer kasir_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "to": "[email protected]" }'

Response

{
  "ok": true,
  "uuid": "9b2f6c1e-4d3a-4f0b-9a77-2c8e5d1f6a30",
  "to": "[email protected]"
}

The sending limit is 30 messages an hour per key, separate from the general limit of 240 requests a minute. Exceeding it returns 429 with a message about the mail limit. If the mail server refuses the message, the answer is 409 with the code mail_error: that is neither a PFR error nor an error in the request, but a failed send, and the request may be repeated later.

  • 400 bad_request
  • 401 unauthorized
  • 404 not_found
  • 422 validation
  • 429 rate_limited
  • 409 mail_error

Flows: sale, refund, copy, pro-forma

Every flow the law prescribes, with the full request body. The response is always the same invoice shape as under POST /invoices.

Normal sale, with a split payment

The most common receipt. One receipt may carry several payment methods; their sum must match the sum of the lines.

{
  "invoiceType": "Normal",
  "transactionType": "Sale",
  "cashier": "Web shop",
  "payments": [
    { "paymentType": "Cash", "amount": 1000.00 },
    { "paymentType": "Card", "amount": 1400.00 }
  ],
  "items": [
    { "name": "T-shirt", "quantity": 2, "unitPrice": 1200.00, "label": "Ђ" }
  ]
}

Refund

If you are returning a whole invoice, the simplest thing is to call POST /invoices/{uuid}/refund. A manual refund through POST /invoices must carry referentDocumentNumber (the PFR number of the original) and buyerId (the buyer identification). Without either of those the request is refused with 422. The lines and amounts are the ones being returned, so they may be part of the original invoice.

{
  "invoiceType": "Normal",
  "transactionType": "Refund",
  "cashier": "Web shop",
  "buyerId": "10:123456789",
  "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-302",
  "referentDocumentDT": "2026-08-19 14:02:33",
  "payments": [{ "paymentType": "Card", "amount": 1200.00 }],
  "items": [
    { "name": "T-shirt", "quantity": 1, "unitPrice": 1200.00, "label": "Ђ" }
  ]
}

Refunds reduce turnover in the reports. A buyer receiving cash is also given a Copy-Refund with a space for a signature.

Voiding your own receipt

A receipt issued in error is voided by refunding the whole amount, with the seller entering their own TIN as the buyer identification, in the form 10:YOUROWNTIN.

{
  "invoiceType": "Normal",
  "transactionType": "Refund",
  "buyerId": "10:111111111",
  "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-302",
  "referentDocumentDT": "2026-08-19 14:02:33",
  "payments": [{ "paymentType": "Card", "amount": 2400.00 }],
  "items": [{ "name": "T-shirt", "quantity": 2, "unitPrice": 1200.00, "label": "Ђ" }]
}

Copy

A copy of an existing invoice is most easily issued through POST /invoices/{uuid}/copy. Manually, through POST /invoices, a copy must carry referentDocumentNumber, otherwise 422.

{
  "invoiceType": "Copy",
  "transactionType": "Sale",
  "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-302",
  "referentDocumentDT": "2026-08-19 14:02:33",
  "payments": [{ "paymentType": "Card", "amount": 2400.00 }],
  "items": [{ "name": "T-shirt", "quantity": 2, "unitPrice": 1200.00, "label": "Ђ" }]
}

Pro-forma, then a fiscal receipt

A pro-forma (ProForma) is not a fiscal receipt: it serves as a quote. When the buyer pays, a real Normal-Sale receipt is issued referencing the pro-forma.

Step 1, the pro-forma:
{
  "invoiceType": "ProForma",
  "transactionType": "Sale",
  "payments": [{ "paymentType": "WireTransfer", "amount": 12000.00 }],
  "items": [{ "name": "Maintenance service", "unit": "мес", "quantity": 1, "unitPrice": 12000.00, "label": "Ђ" }]
}

Step 2, on payment, the fiscal receipt referencing the pro-forma:
{
  "invoiceType": "Normal",
  "transactionType": "Sale",
  "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-310",
  "referentDocumentDT": "2026-08-19 09:15:00",
  "payments": [{ "paymentType": "WireTransfer", "amount": 12000.00 }],
  "items": [{ "name": "Maintenance service", "unit": "мес", "quantity": 1, "unitPrice": 12000.00, "label": "Ђ" }]
}

In the application the "Issue fiscal receipt" button on a pro-forma does exactly this: it prefills the till and sets the reference itself.

The advance flow, from the first payment to the final receipt

An advance line carries a prescribed name per tax label: 10: Аванс (Ђ), 11: Аванс (Е), 12: Аванс (Г) or 13: Аванс (А). The quantity is always 1, and the price is the amount of the advance received. The flow has three kinds of document: an advance receipt for each payment, an advance refund that closes the total of all advances, and the final Normal-Sale receipt.

  1. The first advance receipt. A payment of 5,000.00 arrived by transfer on 18.08 but is posted on 19.08, so dateAndTimeOfIssue carries the actual time of the payment (printed as "ЕСИР време").
    {
      "invoiceType": "Advance",
      "transactionType": "Sale",
      "dateAndTimeOfIssue": "2026-08-18 11:30:00",
      "payments": [{ "paymentType": "WireTransfer", "amount": 5000.00 }],
      "items": [{ "name": "10: Аванс (Ђ)", "quantity": 1, "unitPrice": 5000.00, "label": "Ђ" }]
    }

    The PFR returns, for example, invoiceNumber: "DMX7K2QP-DMX7K2QP-311".

  2. Further advance receipts for each subsequent payment, each referencing the previous one:
    {
      "invoiceType": "Advance",
      "transactionType": "Sale",
      "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-311",
      "referentDocumentDT": "2026-08-18 11:30:00",
      "payments": [{ "paymentType": "WireTransfer", "amount": 3000.00 }],
      "items": [{ "name": "10: Аванс (Ђ)", "quantity": 1, "unitPrice": 3000.00, "label": "Ђ" }]
    }

    The PFR returns, for example, invoiceNumber: "DMX7K2QP-DMX7K2QP-312".

  3. Closing: the advance refund for the whole amount received, referencing the last advance receipt and with a buyerId, which Otkucaj requires on every refund:
    {
      "invoiceType": "Advance",
      "transactionType": "Refund",
      "buyerId": "10:123456789",
      "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-312",
      "referentDocumentDT": "2026-08-19 10:00:00",
      "payments": [{ "paymentType": "WireTransfer", "amount": 8000.00 }],
      "items": [{ "name": "10: Аванс (Ђ)", "quantity": 1, "unitPrice": 8000.00, "label": "Ђ" }]
    }

    The PFR returns, for example, invoiceNumber: "DMX7K2QP-DMX7K2QP-313".

  4. The final Normal-Sale receipt with the actual delivered lines, referencing the advance refund, and advertising text carrying the number and date of the last advance receipt:
    {
      "invoiceType": "Normal",
      "transactionType": "Sale",
      "referentDocumentNumber": "DMX7K2QP-DMX7K2QP-313",
      "referentDocumentDT": "2026-08-19 10:05:00",
      "adText": "Аванс: DMX7K2QP-DMX7K2QP-312 од 19.08.2026.",
      "payments": [{ "paymentType": "WireTransfer", "amount": 8000.00 }],
      "items": [{ "name": "Fitted wardrobe", "quantity": 1, "unitPrice": 8000.00, "label": "Ђ" }]
    }

In the application the "Close advance" button on an advance receipt performs steps 3 and 4 for you: it issues the advance refund for the whole amount and prefills the till for the final receipt with the reference and the advertising text already set. Over the API one call does steps 3 and 4, POST /invoices/{uuid}/close-advance: send the lines of the delivery, and the refund, the references and the mandatory lines of the advertising field are composed by the application. The advance article is sent as 10: Аванс with no unit: the label in brackets is appended by the PFR.

The mode under art. 6 para. 2 of the Rulebook

An installation may run in a mode where the payment methods Card, Check and MobileMoney are disabled and such payments are recorded as cash. One installation runs in exactly one mode, chosen by the administrator in Settings. When the mode is on, a request using one of the disabled methods returns:

422 { "error": { "code": "validation",
  "message": "Начин плаћања „Card“ је искључен подешавањем (режим по чл. 6 ст. 2 Правилника). Унесите као готовину." } }

The integration then sends {"paymentType": "Cash"} for those payments.

Items

The catalogue of items the till offers at a touch. Items are not a condition for issuing an invoice: POST /invoices accepts lines freely, with the name and price from your own system.

GET

/api/v1/items

A list of items, sorted by name, up to 1,000. The active field distinguishes active items from deactivated ones; deactivated items are in the response too.

Authentication
Authorization: Bearer <api-key>
Parameters
none, neither paging nor filters
Response
200, JSON

Request

curl -H "Authorization: Bearer kasir_your_key" \
  https://otkucaj.com/api/v1/items

Response

{
  "items": [
    {
      "id": 12,
      "plu": "1001",
      "name": "T-shirt",
      "unit": "ком",
      "price": 1200.0,
      "label": "Ђ",
      "gtin": "8600000000000",
      "category": "Clothing",
      "active": true
    }
  ]
}
  • 401 unauthorized
  • 429 rate_limited
POST

/api/v1/items

Add an item to the catalogue.

Authentication
Authorization: Bearer <api-key>
Body
JSON
Response
201, the id of the new item
FieldTypeRequiredDescription
namestringyesName, up to 200 characters
labelstringyesA tax label from the code list in force (GET /tax-labels). An unknown label returns 422 with the list of allowed ones
pricenumberno (default 0)Price including VAT, 0 or more, rounded to 2 decimals
plustringnoItem code for quick typing, up to 32 characters, must be unique
unitstringnoUnit of measure, up to 16 characters, default ком
gtinstringnoGTIN or barcode, 8 to 14 digits
categorystringnoCategory, up to 80 characters; becomes a filter button at the till

A new item is always written as active.

Request

curl -X POST https://otkucaj.com/api/v1/items \
  -H "Authorization: Bearer kasir_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "name": "T-shirt", "price": 1200.00, "label": "Ђ",
        "plu": "1001", "gtin": "8600000000000", "unit": "ком" }'

Response 201

{ "id": 12, "ok": true }
  • 400 bad_request
  • 401 unauthorized
  • 422 validation
  • 429 rate_limited
PUTPATCH

/api/v1/items/{id}

A partial update of an item: send only the fields you are changing, the rest stay untouched. Both methods, PUT and PATCH, do the same thing.

Authentication
Authorization: Bearer <api-key>
Path
id: the integer id of the item
Body
JSON, any of plu, name, unit, price, label, gtin, category, active
Response
200, confirmation

Every field goes through the same checks as when creating an item: an unknown tax label, a value longer than its column, a malformed GTIN or a PLU already used by another item all return 422 with an explanation.

Request

curl -X PUT https://otkucaj.com/api/v1/items/12 \
  -H "Authorization: Bearer kasir_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "price": 1350.00, "active": true }'

Response

{ "ok": true }
  • 400 bad_request
  • 401 unauthorized
  • 404 not_found
  • 422 validation
  • 429 rate_limited
DELETE

/api/v1/items/{id}

A soft deactivation: the item is not deleted from the database, because existing invoices still reference it. It gets active: false and disappears from the till. Reactivating it is a PUT with {"active": true}.

Authentication
Authorization: Bearer <api-key>
Path
id: the integer id of the item
Response
200, confirmation

Request

curl -X DELETE https://otkucaj.com/api/v1/items/12 \
  -H "Authorization: Bearer kasir_your_key"

Response

{ "ok": true }

Deactivating an id that does not exist returns 404 not_found; deactivating one that is already inactive returns 200, because the end state is the one you asked for.

  • 401 unauthorized
  • 404 not_found
  • 429 rate_limited

Reports

Both reports calculate turnover by the same rule and with the same queries as the Reports page in the application, so the figures cannot drift apart.

The accounting rule. Turnover counts only issued fiscal receipts, that is Normal and Advance in the issued state. Refunds are subtracted. Pro-forma, copy and training receipts enter no total at all. Only the environment currently in use is counted. The response states the same rule in its rule field.
GET

/api/v1/reports/summary

Turnover for a period, broken down by tax label, payment method and cashier.

Authentication
Authorization: Bearer <api-key>
Parameters
from, to
Response
200, JSON
ParameterTypeDefaultDescription
fromstringtodayDate from, strictly YYYY-MM-DD, includes the whole day
tostringtodayDate to, strictly YYYY-MM-DD, includes the whole day. Must not be earlier than from

A malformed date, or a period where from is later than to, returns 422.

Request

curl -H "Authorization: Bearer kasir_your_key" \
  "https://otkucaj.com/api/v1/reports/summary?from=2026-08-01&to=2026-08-19"

Response

{
  "from": "2026-08-01",
  "to": "2026-08-19",
  "environment": "sandbox",
  "totals": {
    "invoices": 142,
    "turnover": 386400.0,
    "refunds": { "count": 3, "amount": 7200.0 }
  },
  "byTaxLabel": [
    { "label": "Ђ", "name": "О-ПДВ", "rate": 20.0, "turnover": 351200.0 },
    { "label": "Е", "name": "П-ПДВ", "rate": 10.0, "turnover": 35200.0 }
  ],
  "byPaymentType": [
    { "paymentType": "Card", "amount": 244800.0 },
    { "paymentType": "Cash", "amount": 141600.0 }
  ],
  "byCashier": [
    { "cashier": "Web shop", "invoices": 118, "turnover": 302400.0 },
    { "cashier": "Marija", "invoices": 24, "turnover": 84000.0 }
  ],
  "rule": "Turnover counts issued Normal and Advance receipts only; refunds subtract; ProForma, Copy and Training are excluded; only the environment currently in use (sandbox) is counted."
}

What the fields mean: totals.invoices is the number of invoices in the period, refunds included; totals.turnover is turnover with refunds already subtracted; totals.refunds shows the number and total of refunds separately, as a positive number. In byTaxLabel, name and rate are null if the label no longer exists in the code list while invoices carrying it remain in the database. Payment methods are summed from the invoice itself, so for a split payment each part enters its own row.

  • 401 unauthorized
  • 422 validation
  • 429 rate_limited
GET

/api/v1/reports/daily

Turnover and invoice count by day, for the same period and by the same rule as the summary report.

Authentication
Authorization: Bearer <api-key>
Parameters
from, to, both defaulting to today, strictly YYYY-MM-DD
Response
200, JSON

Request

curl -H "Authorization: Bearer kasir_your_key" \
  "https://otkucaj.com/api/v1/reports/daily?from=2026-08-01&to=2026-08-05"

Response

{
  "from": "2026-08-01",
  "to": "2026-08-05",
  "environment": "sandbox",
  "days": [
    { "date": "2026-08-01", "invoices": 12, "turnover": 28800.0 },
    { "date": "2026-08-02", "invoices": 9, "turnover": 19400.0 },
    { "date": "2026-08-05", "invoices": 15, "turnover": 41250.0 }
  ],
  "rule": "Turnover counts issued Normal and Advance receipts only; refunds subtract; ProForma, Copy and Training are excluded; only the environment currently in use (sandbox) is counted."
}

Days with no turnover are omitted, exactly as in the table in the application. If you need an unbroken run of dates, fill the gaps on your side.

  • 401 unauthorized
  • 422 validation
  • 429 rate_limited

Code lists

Two lists an integration needs in order to send valid values, both from the same source the application itself uses.

GET

/api/v1/tax-labels

The tax labels and rates currently in force, from the same source the invoice validator uses (active rows only). A label this call returns is a label POST /invoices will accept.

Authentication
Authorization: Bearer <api-key>
Parameters
none
Response
200, JSON

Request

curl -H "Authorization: Bearer kasir_your_key" \
  https://otkucaj.com/api/v1/tax-labels

Response

{
  "taxLabels": [
    { "label": "Ђ", "name": "О-ПДВ", "rate": 20.0 },
    { "label": "Е", "name": "П-ПДВ", "rate": 10.0 },
    { "label": "Г", "name": "Без ПДВ", "rate": 0.0 }
  ]
}
The list is not fixed. A default installation has the labels Ђ, Е and Г active and the label А (outside VAT) inactive, which means POST /invoices does not accept it by default. Labels and rates change in Settings, and in L-PFR or V-PFR mode the authority is the list the PFR itself provides. So read this call rather than copying the labels into your code.
  • 401 unauthorized
  • 429 rate_limited
GET

/api/v1/categories

The categories of active items, without empty values, sorted alphabetically. The same query the till uses for its category buttons.

Authentication
Authorization: Bearer <api-key>
Parameters
none
Response
200, JSON

Request

curl -H "Authorization: Bearer kasir_your_key" \
  https://otkucaj.com/api/v1/categories

Response

{ "categories": ["Clothing", "Drinks", "Food"] }

Categories are entered alongside an item in the application. Items with no category do not create an empty entry in this list.

  • 401 unauthorized
  • 429 rate_limited

Errors

Every error has the same JSON shape, whatever the path:

{ "error": { "code": "validation", "message": "Рефундација мора имати референтни број оригиналног рачуна (referentDocumentNumber)." } }

Validation messages are written for the person operating the till and are therefore in Serbian. The code is stable, machine-readable and in English: branch on code, and show message to a human.

HTTPcodeCauseWhat to do
400bad_requestThe request body is not valid JSONCheck your serialization and Content-Type: application/json
401unauthorizedNo Authorization header, or the key is wrong or revokedCheck the key; generate a new one in Settings if needed
404not_foundUnknown path, a non-existent invoice UUID, or an item idCheck the path and the identifier
422validationThe request fails validation; the message states exactly which field and whyCorrect the request. Do not repeat the same request unchanged: the result will always be the same
429rate_limitedMore than 240 requests a minute per key, or more than 30 e-mails an hourWait for the window to expire, then continue at a slower pace
409pfr_errorThe PFR refused the request or is unreachableThe invoice was NOT issued and nothing was written. Check GET /status, then repeat the same request
409mail_errorThe message with the receipt could not be sentThe invoice exists and is untouched. The send may be repeated later
500server_errorAn unexpected error on our side; the response carries a ref identifierSend us the ref value. The request may be repeated with the same idempotency key
The most important rule. On a 409 no invoice was created and the request is safe to repeat; on a 422 the request needs correcting, and repeating it unchanged is pointless. If your system retries automatically, let it retry only 429, 409 and network failures, never 422. Best of all, send an idempotency key and let a retry be safe by construction.

WooCommerce and integrations

A ready plugin connects a WooCommerce shop without a line of code: otkucaj-fiskalizacija.zip.

  1. In WordPress admin open Plugins · Add New · Upload Plugin, choose the downloaded zip and activate the plugin.
  2. In Otkucaj (Settings · API keys) generate a key and copy it immediately: it is shown only once.
  3. Open Settings · Otkucaj fiscalization in WordPress and enter: the address (https://otkucaj.com), the API key, the order status that triggers issuing a receipt (the one at which, as we understand art. 6 para. 1 of the Law on Fiscalization, the sale or the receipt of an advance takes place; settle it with your accountant) and the mapping of WooCommerce payment methods to fiscal ones (for example cards to Card, cash on delivery to Cash, bank transfer to WireTransfer).
  4. That is all. When an order moves to the chosen status, the plugin issues a receipt, exactly once per order, and writes the PFR receipt number and the verification link into the order itself, visible to you and to the buyer. The receipt still has to be delivered to the buyer, for example by e-mail (POST /invoices/{uuid}/email).

Per-product tax label: an individual product can be given the meta field otkucaj_label (a value from GET /tax-labels); products without it use the default label from the plugin settings.

Telegram notifications. In the application (Settings · Notifications) the owner can turn on a Telegram message for every receipt issued through the API: receipt type, amount, payment methods, PFR number, counter and the verification link arrive in the chosen chat or group as soon as the order is fiscalized.

Example: plain PHP

function otkucaj_issue_invoice(string $apiKey, array $body, string $idemKey): array
{
    $ch = curl_init('https://otkucaj.com/api/v1/invoices');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json; charset=utf-8',
            'Idempotency-Key: ' . $idemKey,   // e.g. your order number
        ],
        CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE),
    ]);
    $raw  = curl_exec($ch);
    $err  = curl_error($ch);
    $http = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($raw === false) {
        // safe to retry with the SAME idempotency key
        throw new RuntimeException("Network error: $err");
    }
    $json = json_decode($raw, true);
    if ($http === 201) {
        return $json;                                        // whole invoice: uuid, pfr, journal...
    }
    $code = $json['error']['code'] ?? 'unknown';
    $msg  = $json['error']['message'] ?? $raw;
    if ($http === 422) {
        throw new InvalidArgumentException("Invalid request ($code): $msg"); // do not retry
    }
    throw new RuntimeException("Otkucaj HTTP $http ($code): $msg");          // 429/409: retry later
}

$invoice = otkucaj_issue_invoice($apiKey, [
    'invoiceType' => 'Normal',
    'transactionType' => 'Sale',
    'cashier' => 'Web shop',
    'payments' => [['paymentType' => 'Card', 'amount' => 2400.00]],
    'items' => [['name' => 'T-shirt', 'quantity' => 2, 'unitPrice' => 1200.00, 'label' => 'Ђ']],
], 'order-' . $orderId);
// store alongside the order:
// $invoice['uuid'], $invoice['pfr']['invoiceNumber'], $invoice['pfr']['verificationUrl']

Example: Node.js

async function issueInvoice(apiKey, body, idemKey) {
  const res = await fetch('https://otkucaj.com/api/v1/invoices', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json; charset=utf-8',
      'Idempotency-Key': idemKey,          // e.g. your order number
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30000),
  });
  const json = await res.json();
  if (res.status === 201) return json;
  const { code, message } = json.error ?? {};
  const e = new Error(`Otkucaj HTTP ${res.status} (${code}): ${message}`);
  e.retryable = res.status === 429 || res.status === 409; // 422 is never retried
  throw e;
}

const invoice = await issueInvoice(process.env.OTKUCAJ_KEY, {
  invoiceType: 'Normal',
  transactionType: 'Sale',
  cashier: 'Web shop',
  payments: [{ paymentType: 'Card', amount: 2400.00 }],
  items: [{ name: 'T-shirt', quantity: 2, unitPrice: 1200.00, label: 'Ђ' }],
}, `order-${orderId}`);
console.log(invoice.pfr.invoiceNumber, invoice.pfr.verificationUrl);

Example: Python

import requests

def issue_invoice(api_key: str, body: dict, idem_key: str) -> dict:
    r = requests.post(
        'https://otkucaj.com/api/v1/invoices',
        json=body,
        headers={
            'Authorization': f'Bearer {api_key}',
            'Idempotency-Key': idem_key,     # e.g. your order number
        },
        timeout=30,
    )
    if r.status_code == 201:
        return r.json()
    err = r.json().get('error', {})
    if r.status_code == 422:
        raise ValueError(f"Invalid request: {err.get('message')}")  # do not retry
    raise RuntimeError(f"Otkucaj HTTP {r.status_code} ({err.get('code')}): {err.get('message')}")

invoice = issue_invoice(API_KEY, {
    'invoiceType': 'Normal',
    'transactionType': 'Sale',
    'cashier': 'Web shop',
    'payments': [{'paymentType': 'Card', 'amount': 2400.00}],
    'items': [{'name': 'T-shirt', 'quantity': 2, 'unitPrice': 1200.00, 'label': 'Ђ'}],
}, f'order-{order_id}')
print(invoice['pfr']['invoiceNumber'], invoice['pfr']['verificationUrl'])

Good practice

  • Check environment and fiscal before going live. An integration pointed at the test environment issues receipts that look real and are not.
  • Issue the receipt at the moment of the sale or of an advance. As we understand art. 6 para. 1 of the Law on Fiscalization, the receipt is issued when the goods are supplied or the service rendered, or when a payment in advance is received (an advance receipt), whenever and however payment is made. A card payment taken at checkout, before dispatch, is an advance.
  • Send an idempotency key with every issue. Your own order number is ideal. A network interruption after a successful issue is the most common cause of a duplicate receipt, and this removes the problem rather than working around it.
  • Store the uuid and pfr.invoiceNumber with the order. Without them you cannot later issue a copy or a refund, e-mail the receipt, or fetch the journal.
  • Read the code lists, do not copy them. Take the tax labels from GET /tax-labels when configuring the integration, because the list differs from installation to installation.
  • Set a 30-second timeout, so a call to the API cannot block your application indefinitely.
  • Never call the API from a customer's browser or from a mobile app: the key would be public. All traffic goes from your server.
  • Develop and test in demo mode. Moving to an L-PFR or a V-PFR is a settings change in Otkucaj, with no change at all in your code.

Changelog

VersionWhat is new
1.3.6The receipt as a document also on an A5 sheet (the fiscal slip, the form a courier packs with the parcel) and as a PDF rendered on the server: GET /invoices/{uuid}/document?paper=a5&format=pdf. New POST /invoices/{uuid}/close-advance closes an advance in one call (Аванс Рефундација + the final Промет Продаја with the reference and the mandatory advertising field), safely repeatable. GET /status reports documents. WooCommerce plugin 1.1.0: advance receipt on card payment, final receipt at dispatch, a public PDF link for the courier, refunds from the order panel. New prefetchDocument on an issuing call renders the sheet as soon as the response is out, for a partner that downloads it while packing. Plugin 1.2.0: the downloaded PDF is kept locally and the e-mails are sent only after the document has left.
1.3.5The whole self-assessment list (Техничко упутство §7.4.2, sections 9 to 16) was audited item by item. The important one: the demo simulator no longer prints a document headed "ФИСКАЛНИ РАЧУН", it carries the not-a-fiscal-receipt notice instead, and the advance article code is derived from the tax RATE rather than from the label letter. Added: a default advertising text, the PFR handshake shown on the till, a tax-rate panel a cashier can open, GTIN on the receipt and wrapping of long article names. The manual gained chapters 6.1, 18.2.1, 18.6.1, 25 and 26.
1.3.4A receipt can now be given to the buyer as an A4 document ("Document" on the receipt page), and the e-mail was rebuilt into the same shape, with the QR code inside the message. The fiscal slip stays in both, character for character, exactly as the PFR signed it. Three faults fixed: a long business name ran off the edge of the receipt (the PFR does not wrap lines, a thermal printer does), roll printing never took effect because the @page rule was invalid CSS, and the HTML part of the receipt e-mail arrived with broken Cyrillic characters.
1.3.3The first version that has actually spoken to a real PFR. Against the Tax Administration test security element and the sandbox V-PFR, three faults surfaced that demo mode could never show: the RequestId header has to be a number (with a GUID every single request came back 400), the journal arrives with CRLF line endings, which kept the mandatory "ОВО НИЈЕ ФИСКАЛНИ РАЧУН" notice at normal size on every Copy, Pro-forma and Training receipt, and a real PFR does not print the Повраћај (change) line at all, so the ESIR now adds it below the fiscal block. Tax rates are read from currentTaxRates, and a PFR error is now legible whether it arrives as plain text or as modelState.
1.3.2Payments now carry the amount actually tendered. The sum may exceed the invoice total: the difference is change, shown on the till, printed as Повраћај and returned as change. Less than the total is still refused, and a refund takes no change. The payment-method report nets the change out of cash, so an over-tendered sale does not inflate turnover. An e-mailed receipt now carries an HTML alternative in which the verification address is a real hyperlink and the "ОВО НИЈЕ ФИСКАЛНИ РАЧУН" notice keeps its double size.
1.3.1New buyerCostCenterId field (Опционо поље купца) on the till and in the API response; a copy carries it over from the original. The receipt register gained a search for one particular receipt, by PFR number, counter, referent number, buyer, amount or article, across the whole journal. The "ОВО НИЈЕ ФИСКАЛНИ РАЧУН" notice renders at double size also when the PFR prints it inside a banner line. The receipt journal is always Cyrillic, whatever the interface language. Settings show the installation serial number.
1.3Separate environments: test and production, each with its own security element, PAC and ESIR number. An invoice records its environment, and lists and reports show only the active one. New fields: environment and fiscal in GET /status and in every invoice, verificationQRCode (the QR the PFR drew itself), clientRequestId. Idempotency: the Idempotency-Key header or the clientRequestId field prevents a double issue after a connection breaks. CORS is enabled, so the API can be called from a browser. buyerId is checked for the code:value form, at most 20 characters.
1.2The API was extended: GET /me, GET /tax-labels, GET /categories, POST /invoices/{uuid}/refund, POST /invoices/{uuid}/copy, POST /invoices/{uuid}/email, GET /reports/summary and GET /reports/daily. In the application itself: account registration with approval, Cloudflare Turnstile protection on sign-in, Telegram notifications for issued receipts, a new dashboard, a collapsible sidebar, item categories, per-line and whole-receipt discounts, conversion of a pro-forma into a fiscal receipt, automatic closing of advances, e-mailing receipts.
1.1An offline queue at the till (a receipt waits in a local queue and is issued when the connection returns), split payments on one receipt, the WooCommerce plugin.
1.0The first version: issuing invoices, the invoice list and detail, the journal, items, API keys.

The v1 API contract is stable across all the versions listed: capabilities were added, existing fields did not change.

Questions and help with an integration: the contact form. The manual for working in the application itself: Manual (PDF).