A REST API for issuing fiscal receipts in Serbia (e-fiscalization with the Serbian Tax Administration). This is the entire API surface — one call for a sale, one each for an advance receipt and a refund.
| Base URL | https://api.fiskalapi.rs |
|---|---|
| Format | JSON (Content-Type: application/json) |
| Authentication | Authorization: Bearer <API_KEY> |
| Currency | All amounts are in Serbian dinars (RSD). |
Note on naming: request and response fields mix English and Serbian names (items, brojRacuna). That is deliberate and stable — the names are part of the public contract and are not going to be renamed under existing integrations.
You get two keys. fapi_test_… runs in sandbox mode — the real flow, test receipts, not billed and not counted against your limit. fapi_live_… issues real fiscal receipts. The calls are identical; you only swap the key.
POST /api/racuni — a sale (fiscal receipt)
curl https://api.fiskalapi.rs/api/racuni \
-H "Authorization: Bearer fapi_live_…" \
-H "Content-Type: application/json" \
-d '{
"paymentType": "Card",
"items": [
{ "name": "Pro subscription/pc", "quantity": 1, "unitPrice": 2400, "labels": ["А"] }
],
"kupac": { "email": "buyer@example.com" }
}'
{
"mode": "live",
"brojRacuna": "NRXJ2JLE-XXXXXXXX-1",
"qrKod": "data:image/png;base64,…",
"verifikacija": "https://suf.purs.gov.rs/v/?vl=…",
"zurnal": "…plain-text fiscal receipt…",
"brojaci": { "invoiceCounter": 1, "totalCounter": 1 },
"sdcDateTime": "2026-08-28T12:41:05+02:00"
}
brojRacuna is the PFR receipt number, qrKod the QR code image, verifikacija the Tax Administration verification link, and zurnal the plain-text receipt (the electronic journal entry).
sdcDateTime is the timestamp the Tax Administration assigned to the receipt. Store it — you pass it back as referentDocumentDT when you refund that receipt. It is returned in sandbox mode too.
| Field | Description |
|---|---|
items[] | Line items: name, quantity, unitPrice (RSD), labels (tax labels), optionally gtin. Instead of line items you may send amountRsd + description. |
payments[] / paymentType | Payment method: Cash, Card, WireTransfer, Voucher, MobileMoney, Check, Other. |
labels | Tax labels from GET /api/status. For example А = outside VAT (0%), Ђ = standard rate (20%), Е = reduced rate (10%). |
buyerId | Optional — buyer identification (e.g. "10:PIB") when the receipt is issued to a company. |
transactionId | Optional but strongly recommended — your own ID, used as the idempotency key. A repeated call with the same ID returns the same receipt and "ponovljen": true ("repeated"); it does not issue a new one. |
occurredAt | Optional — the time of payment (the ESIR timestamp), only when the money was received before the receipt is issued. Send an absolute instant in ISO 8601 with an offset: 2026-08-04T15:20:00+02:00 or 2026-08-04T13:20:00Z. A value without an offset is read as Belgrade local time. |
А, Ђ and Е are Cyrillic characters (U+0410, U+0402, U+0415), not the visually identical Latin A and E. A Latin letter here is rejected as an unknown label with a 400. Copy the values from GET /api/status rather than typing them, make sure your source files are UTF-8, and be careful with editors or spreadsheets that "helpfully" normalise characters.
POST /api/racuni/avans — for subscriptions and prepayments. Same body structure as a sale.
You send the receipt to your buyer, from your own mail system and your own domain: take qrKod and verifikacija from the response and put them into your own order confirmation. The "kupac": { "email": "…" } field is ignored by default (the response then carries a napomena, a note, and mejlPoslat is null) — such mail would go out from our domain, and bounces from other people's buyers would burn the sending reputation we rely on to deliver API keys and alerts.
POST /api/racuni/obuka — a receipt of type Обука ("training") issued against your real security element. It travels the whole chain to the Tax Administration but is not a sale: no tax liability, it does not enter your sales records, and we do not bill it. Use it to confirm the register works before the first real purchase. Same body structure as a sale.
POST /api/racuni/refundacija — requires referentDocumentNumber (the PFR number of the original receipt) and referentDocumentDT.
Both values come from the response of the receipt you are reversing: brojRacuna → referentDocumentNumber, sdcDateTime → referentDocumentDT. The same holds in the sandbox, so a refund can be exercised end to end with a test key.
Partial refunds: a refund may carry the same transactionId as the sale it reverses — to us those are two different documents. But a second partial refund of the same order must use a different transactionId (for example 1284-ref-1, 1284-ref-2). If you send the same one, you get the first refund back with "ponovljen": true. That is not a bug but a safeguard: from our side we cannot tell "issue the second instalment" apart from "the webhook fired twice".
Send your own transactionId with every call. A repeated call with the same ID returns the same receipt and "ponovljen": true (HTTP 200, identical response shape) — no second receipt is issued. That covers repeated webhooks, double clicks and concurrent calls.
Without transactionId there is NO such protection. Every call is then a new receipt: if your payment processor repeats a webhook, a second real fiscal receipt is issued for the same purchase — and that can only be corrected by a refund, at your company's expense. That is why the response to such a call also carries a napomena reminding you. The field is formally optional only for the sake of older integrations; treat it as required.
The key is client + receipt type + direction + transactionId, so the same ID may be reused across a sale, an advance and a training receipt — those are different documents and do not cancel each other out.
GET /api/racuni/{id} — returns an already issued receipt, by your transactionId or by its PFR receipt number. For when you lose the response from issuing: the document has already been reported to the Tax Administration, so there has to be a way to read it back.
You always get brojRacuna, iznos (amount), sdcDateTime and verifikacija, plus qrKod, zurnal and brojaci for as long as we still hold the issuing record (35 days). If the same transactionId carries several documents (a sale and then a refund), racun is the most recent one and the whole chain is in racuni.
You see only your own and only real (live) receipts — test receipts are not retained. An unknown ID returns 404. In the sandbox, therefore, the POST response is the only source — it contains every field, sdcDateTime included.
GET /api/status — taxpayer details, ESIR details and the current tax rates (labels and percentages). Always pull the rates from the Tax Administration; never hard-code them.
GET /api/usage — receipt count, monthly cost and limit utilisation for the current month (optionally ?month=YYYY-MM).
Every error response carries a Serbian message in error and, where a translation exists, the same message in English in errorEn. Read errorEn and fall back to error:
{
"error": "Nalog nije aktiviran. Onboarding nije završen. Koristi test ključ za sandbox.",
"errorEn": "The account is not activated yet — onboarding is not finished. Use the test key (fapi_test_…) for the sandbox."
}
The Serbian error field is the original and will not change — the merchant's own dashboard and the e-commerce modules display it to the Serbian shop owner. A message we have not translated simply has no errorEn, rather than a machine translation; the same applies to detail passed through from OpenSSL or from the Tax Administration, which is quoted verbatim. Where a response carries uputstvo (guidance) or kakoDalje (next steps), the English versions are uputstvoEn and kakoDaljeEn.
| Code | Meaning |
|---|---|
401 | Invalid API key. |
403 | The account is not active or has been suspended (an outstanding invoice). |
402 | The monthly receipt limit has been reached. |
400 | Malformed request body (for example, the payments do not add up to the total, or an unknown tax label). |
409 | An earlier attempt with the same transactionId is unresolved — the receipt may already have been issued, so we will not issue another. Send us the transactionId and we will check with the Tax Administration. |
502 | An error towards the Tax Administration's V-PFR. If the response carries "statusRacuna": "nepoznato" ("unknown"), the connection broke after the request was sent and the receipt may have been issued — do not retry automatically. |
If the request reaches the Tax Administration but the response never arrives (a timeout or a dropped connection), nobody on our side knows whether the receipt was issued. You then get a 502 with "statusRacuna": "nepoznato".
Retrying automatically could in that case produce a second real fiscal receipt for the same purchase — a mistake correctable only by a refund. That is why a repeated call with the same transactionId returns 409 until it is established what actually happened. If the receipt turns up in the records in the meantime, it resolves itself and you get it back with "ponovljen": true.
Everything that gets presented or billed is computed in Belgrade time (Europe/Belgrade, CET/CEST). Days and months are cut at Belgrade midnight — a receipt issued on the 1st at 00:30 belongs to the new month for invoicing and for the KPO ledger. In requests, send absolute instants with an offset and let us do the conversion; see occurredAt above.
| Date | What changed |
|---|---|
2026-08-28 |
The sandbox returns sdcDateTime and brojaci — a response with a test key now carries the same fields as live. A refund requires referentDocumentDT, and test receipts are not retained, so that timestamp has to come from the POST response itself.
|
2026-08-22 |
New: GET /api/racuni/{id} — an issued receipt can now be read back, by transactionId or by PFR number. Previously the POST response was the only copy.
Corrected in this documentation: kupac.email is ignored by default — the seller emails the buyer. It previously said the receipt "reaches the buyer by email immediately".
|
2026-08-09 |
occurredAt is read as an absolute instant. Previously the value was passed to the Tax Administration verbatim, and the V-PFR prints the digits unchanged — so the time on the receipt came out shifted by the zone offset, and integrations that noticed started sending Belgrade wall-clock time labelled Z to make the printout correct.
From this date we do the conversion: send the real instant ( 2026-08-04T13:27:37Z or 2026-08-04T15:27:37+02:00) and the receipt reads 15:27:37. A value without an offset is read as Belgrade time.
If your integration used to add an hour or two to make the printout correct — remove that, or receipts will now carry a time shifted forward. We detect that pattern and leave a warning for you in My account. |
2026-08-09 |
Duplicate protection and the 409 code. A repeated transactionId returns the same receipt instead of a new one; a connection broken after sending yields 502 with "statusRacuna": "nepoznato", and the next attempt 409 until it is resolved. |
2026-08-09 |
Times in displays and billing are Belgrade times. Days and months are cut at Belgrade midnight — a receipt issued on the 1st at 00:30 belongs to the new month (invoices, KPO ledger). |
Questions? Contact · Home · Srpska verzija