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.
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.
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.
Quick start
From a key to the first receipt in three steps.
-
Check the connection and the state of the PFR.
curl -H "Authorization: Bearer kasir_your_key" \ https://otkucaj.com/api/v1/status
-
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
201with the whole invoice: keep theuuidandpfr.invoiceNumber. The full response shape is under POST /invoices. -
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
/api/v1/status
The state of the application and of the connection to the PFR. Use it to check the configuration and for monitoring.
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.
/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.
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."
}
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
| Limit | Value | What happens when exceeded |
|---|---|---|
| Requests per key | 240 in 60 seconds | 429 rate_limited |
| E-mailed receipts per key | 30 messages an hour | 429 rate_limited, with its own message |
| Lines per invoice | 1 to 500 | 422 validation |
Invoices per page (GET /invoices) | 100 | next page through page |
Items per call (GET /items) | 1,000 | the excess is not returned; there is no paging |
| Length of a line and item name | 200 characters | 422 validation |
Length of buyerId | 20 characters, form code:value | 422 validation |
Length of adText | 500 characters | 422 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.
| Environment | What it is | Are the receipts fiscal |
|---|---|---|
sandbox | The 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. |
production | The 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 returns422. - The key is reserved before the PFR is called, so two simultaneous retries cannot both get through.
- A retry with the same key returns
201and the same invoice, with the sameuuidand 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.
/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.
| Field | Type | Required | Description |
|---|---|---|---|
invoiceType | string | no (default Normal) | Normal, Advance, ProForma, Training, Copy |
transactionType | string | no (default Sale) | Sale or Refund |
items[] | array | yes | 1 to 500 lines; the total must be greater than 0 |
items[].name | string | yes | Line name, up to 200 characters |
items[].unit | string | no | Unit of measure, default ком |
items[].quantity | number | yes | Greater than 0 and at most 999,999; rounded to 3 decimals (for example 0.375) |
items[].unitPrice | number | yes | Unit price including VAT, 0 or more, rounded to 2 decimals |
items[].totalAmount | number | no | Line total. The server calculates it itself as quantity × price; the value you send is not used |
items[].label | string | yes | A tax label from the code list in force, see GET /tax-labels. Rates are determined by the PFR alone |
items[].gtin | string | no | GTIN or barcode, 8 to 14 digits |
items[].itemId | number | no | The 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[] | array | no | Payment methods. If omitted or empty, the whole amount goes as Cash |
payments[].paymentType | string | yes | Cash, Card, Check, WireTransfer, Voucher, MobileMoney (instant payment), Other |
payments[].amount | number | yes | Amount. 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) |
cashier | string | no | The 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 |
buyerId | string | conditional | Buyer 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 |
buyerCostCenterId | string | no | The buyer's optional field (cost centre) |
referentDocumentNumber | string | conditional | The PFR number of the original invoice. Mandatory for Refund and for Copy |
referentDocumentDT | string | no | The time of the original invoice, YYYY-MM-DD HH:MM:SS |
dateAndTimeOfIssue | string | no | ESIR 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) |
adText | string | no | Advertising 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 |
clientRequestId | string | no | Idempotency 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.
/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.
| Parameter | Type | Description |
|---|---|---|
from | string | Date from, YYYY-MM-DD (includes the whole day from 00:00:00) |
to | string | Date to, YYYY-MM-DD (includes the whole day to 23:59:59) |
type | string | Invoice type: Normal, Advance, ProForma, Training, Copy |
page | number | Page, from 1 (default 1), 100 invoices each, at most 10000 |
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
/api/v1/invoices/{uuid}
One invoice by UUID, in the full shape described under POST /invoices.
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
/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.
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
/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.
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,formatorbasenot acceptable; configuration - this installation cannot render PDF (ask for HTML)
/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.
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 field | Type | Default | Description |
|---|---|---|---|
buyerId | string | the buyer from the original | Buyer 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 |
buyerCostCenterId | string | from the original | The buyer's cost centre |
payments | array | the payments from the original | How the money is returned. The sum must match the invoice total |
cashier | string | API | The cashier name on the refund receipt, at most 50 characters |
adText | string | empty | Advertising 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
/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.
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
/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.
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
/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.
| Body field | Type | Required | Description |
|---|---|---|---|
to | string | yes | The 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.
-
The first advance receipt. A payment of 5,000.00 arrived by transfer on 18.08 but is posted on 19.08, so
dateAndTimeOfIssuecarries 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". -
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". -
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". -
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.
/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.
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
/api/v1/items
Add an item to the catalogue.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Name, up to 200 characters |
label | string | yes | A tax label from the code list in force (GET /tax-labels). An unknown label returns 422 with the list of allowed ones |
price | number | no (default 0) | Price including VAT, 0 or more, rounded to 2 decimals |
plu | string | no | Item code for quick typing, up to 32 characters, must be unique |
unit | string | no | Unit of measure, up to 16 characters, default ком |
gtin | string | no | GTIN or barcode, 8 to 14 digits |
category | string | no | Category, 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
/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.
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
/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}.
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.
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./api/v1/reports/summary
Turnover for a period, broken down by tax label, payment method and cashier.
| Parameter | Type | Default | Description |
|---|---|---|---|
from | string | today | Date from, strictly YYYY-MM-DD, includes the whole day |
to | string | today | Date 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
/api/v1/reports/daily
Turnover and invoice count by day, for the same period and by the same rule as the summary report.
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.
/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.
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 }
]
}
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
/api/v1/categories
The categories of active items, without empty values, sorted alphabetically. The same query the till uses for its category buttons.
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.
| HTTP | code | Cause | What to do |
|---|---|---|---|
| 400 | bad_request | The request body is not valid JSON | Check your serialization and Content-Type: application/json |
| 401 | unauthorized | No Authorization header, or the key is wrong or revoked | Check the key; generate a new one in Settings if needed |
| 404 | not_found | Unknown path, a non-existent invoice UUID, or an item id | Check the path and the identifier |
| 422 | validation | The request fails validation; the message states exactly which field and why | Correct the request. Do not repeat the same request unchanged: the result will always be the same |
| 429 | rate_limited | More than 240 requests a minute per key, or more than 30 e-mails an hour | Wait for the window to expire, then continue at a slower pace |
| 409 | pfr_error | The PFR refused the request or is unreachable | The invoice was NOT issued and nothing was written. Check GET /status, then repeat the same request |
| 409 | mail_error | The message with the receipt could not be sent | The invoice exists and is untouched. The send may be repeated later |
| 500 | server_error | An unexpected error on our side; the response carries a ref identifier | Send us the ref value. The request may be repeated with the same idempotency key |
WooCommerce and integrations
A ready plugin connects a WooCommerce shop without a line of code: otkucaj-fiskalizacija.zip.
- In WordPress admin open Plugins · Add New · Upload Plugin, choose the downloaded zip and activate the plugin.
- In Otkucaj (Settings · API keys) generate a key and copy it immediately: it is shown only once.
- 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 toCard, cash on delivery toCash, bank transfer toWireTransfer). - 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.
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
environmentandfiscalbefore 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
uuidandpfr.invoiceNumberwith 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
| Version | What is new |
|---|---|
| 1.3.6 | The 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.5 | The 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.4 | A 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.3 | The 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.2 | Payments 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.1 | New 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.3 | Separate 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.2 | The 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.1 | An 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.0 | The 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).