Headless web shop na Next.js fiskalizuje tako što route handler primi potvrdu naplate od procesora, upiše zadatak u red čekanja, a radnik iz reda pozove Tezga eKasa API sa externalId porudžbine. Tezga fiskalizuje preko V-PFR-a Poreske uprave, pošalje kupcu mejl sa računom i vrati shopu webhook racun.fiskalizovan. Front nikad ne vidi ključ ni PFR.
- Pet slojeva: front (Next.js), route handleri, red čekanja sa radnikom, Tezga API sa V-PFR-om, odlazni webhook nazad u shop.
- Fiskalizacija je asinhrona: callback procesora se završava za milisekunde, a račun nastaje iz reda čekanja.
- Jedna porudžbina, jedan
externalId, jedan dokument; ponavljanje je bezopasno i na strani shopa i na strani kase. - Tajne (ključ
tzg_, HMAC tajna, tajna procesora) žive samo u serverskom okruženju, nikad uNEXT_PUBLIC_promenljivama. - Svaka porudžbina ima vidljivo stanje fiskalizacije i HTTP kod poslednjeg pokušaja, pa se greške vide pre nego što ih kupac prijavi.
Zašto fiskalizacija u headless shopu mora da bude zaseban sloj?
Headless shop razdvaja front od pozadine: Next.js prikazuje katalog i korpu, a porudžbine, naplata i isporuka žive u API rutama i spoljnim servisima. Fiskalizacija je još jedan spoljni servis, sa dva svojstva koja je čine drugačijom od slanja mejla ili upisa u CRM. Prvo, dokument koji nastane ne može da se obriše, samo da se stornira ili refundira. Drugo, Zakon o fiskalizaciji traži da račun nastane u trenutku prometa (isporuke ili naplate, šta pre nastupi), a ne kad je zgodno kodu.
Zato fiskalizacija ne sme da bude poziv unutar callbacka procesora, jer callback stiže više puta i mora da se završi brzo, a ne sme ni da bude poziv iz komponente, jer bi ključ završio u pregledaču. Mora da bude zaseban sloj: red čekanja sa radnikom koji zna da ponovi, da prepozna 202 i da zapiše šta se desilo. Sve što sledi je razrada tog jednog pravila. Šta kasa nudi shopu i sajtu, uključujući dvosmernu vezu za WooCommerce i Shopify koja ovde nije potrebna, opisano je na strani web shop, sajt i API.
Kako izgleda pet slojeva referentne arhitekture?
Poziv teče odozgo nadole, od fronta do Poreske uprave, a odlazni webhook vraća stanje dokumenta nazad u route handler i porudžbinu.
Slojevi 1, 2, 3 i 5 su vaš kod. Sloj 4 je Tezga eKasa, odobreni ESIR pod brojem ИБ 1597, koja radi preko V-PFR-a, pa shopu ne treba fiskalni štampač ni hardver. Adresa API-ja je https://pos.narbiz.com, opis poziva je na strani API za fiskalizaciju, a mašinski opis u OpenAPI dokumentu.
Tabela odgovornosti: ko šta radi
| Sloj | Radi | Ne radi | Tajne koje vidi |
|---|---|---|---|
| Front (Next.js) | Prikaz, korpa, slanje porudžbine serveru, prikaz stanja | Ne zove kasu, ne zove procesor direktno sa tajnom | Nijednu |
| Route handleri | Upis porudžbine, prijem callbacka procesora sa proverom potpisa, upis zadatka | Ne zove kasu sinhrono | Tajna procesora |
| Red čekanja i radnik | Poziv kase, grananje po kodu, ponavljanje, upis stanja | Ne menja porudžbinu van stanja fiskalizacije | Ključ tzg_, HMAC tajna |
| Tezga eKasa | Idempotentnost, V-PFR, mejl kupcu, javni PDF, webhook | Ne odlučuje kad nastaje promet; to je vaš radnik | Svoje |
| Webhook handler | Provera X-Tezga-Signature, idempotentnost po X-Tezga-Delivery, upis broja računa | Ne pokreće nove račune | Tajna webhooka |
Kako teče jedna porudžbina od klika do webhooka?
Callback procesora (korak 4) se završava upisom zadatka; sve od koraka 6 radi radnik i kasa, a shop saznaje ishod iz webhooka u koraku 10.
Tri detalja u sekvenci nose ceo dizajn. U koraku 4 handler proverava potpis procesora i upisuje zadatak u transakciji sa porudžbinom, pa vraća 200 procesoru za nekoliko milisekundi; ako procesor ponovi callback, drugi upis pada na jedinstveni ključ zadatka i ništa se ne dešava. U koraku 6 radnik šalje externalId jednak broju porudžbine, pa je i sa strane kase nemoguće dobiti dva dokumenta. U koraku 10 handler proverava HMAC potpis i pamti X-Tezga-Delivery, jer i webhook može da stigne dvaput.
Kako se pišu route handleri i radnik u Next.js?
Handler za callback procesora ne zna ništa o kasi. Njegov posao je da proveri potpis, obeleži porudžbinu kao plaćenu i upiše zadatak.
// app/api/naplata/route.js (server)
import { proveriPotpisProcesora } from "@/lib/procesor";
import { db } from "@/lib/db";
export async function POST(req) {
const sirovo = await req.text();
if (!proveriPotpisProcesora(sirovo, req.headers)) {
return new Response("potpis", { status: 401 });
}
const dogadjaj = JSON.parse(sirovo);
await db.transaction(async (tx) => {
await tx.porudzbine.update(dogadjaj.brojPorudzbine, { stanje: "placeno" });
await tx.zadaci.upsert({
kljuc: "fiskalizuj:" + dogadjaj.brojPorudzbine, // jedinstven, drugi upis ne prolazi
tip: "fiskalizuj",
porudzbina: dogadjaj.brojPorudzbine,
pokusaji: 0,
sledeci: new Date()
});
});
return new Response("ok", { status: 200 });
}
Radnik je pozadinska funkcija ili cron ruta koju hosting poziva na minut ili dva; na hostingu koji ima trajne procese to je običan proces. Radnik uzima zadatke čiji je sledeci prošao, zaključa ih i za svaki pozove kasu.
// lib/radnik.js (server)
const RAZMACI = [30, 120, 600, 1800, 3600]; // sekunde
export async function obradi(zadatak) {
const p = await db.porudzbine.sa_stavkama(zadatak.porudzbina);
const odgovor = await fetch(
"https://pos.narbiz.com/api/integrations/order?provider=custom",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tezga-Api-Key": process.env.TEZGA_API_KEY
},
body: JSON.stringify({
externalId: "shop-" + p.broj,
tipDokumenta: "racun",
tok: p.pib ? "fiskalni_i_sef" : "fiskalni",
nacinPlacanja: p.nacinPlacanja, // "Platna kartica", "Prenos na račun"...
posaljiMejl: true,
kupac: { naziv: p.ime, pib: p.pib, email: p.email },
stavke: p.stavke // uključuje i dostavu kao stavku
})
}
);
const kod = odgovor.status;
await db.porudzbine.update(p.broj, { fiskalKod: kod, fiskalVreme: new Date() });
if (kod === 200) return zavrsi(zadatak, await odgovor.json());
if (kod === 202) return zavrsi(zadatak, { stanje: "ceka_pfr" }); // webhook zatvara
if (kod === 400 || kod === 401 || kod === 402) return naCoveka(zadatak, kod);
return ponovi(zadatak, RAZMACI[Math.min(zadatak.pokusaji, RAZMACI.length - 1)]);
}
Grananje po kodu je ono što razlikuje integraciju koja preživi zastoj PFR-a od one koja ne preživi. Kod 200 znači izdat dokument; 202 znači da je V-PFR trenutno nedostupan i da Tezga sama ponavlja, pa radnik zadatak zatvara i čeka webhook; 400 (loše telo), 401 (ključ) i 402 (kredit potrošen u paketu „Po računu”) idu čoveku, jer se ponavljanjem ne popravljaju; 429 i 5xx se ponavljaju sa rastućim razmakom. Isečci za Laravel, WordPress i no-code sajtove po istom obrascu su u tekstu fiskalizacija za custom sajt.
Kako se prima webhook i zatvara krug?
Odlazni webhook se uključuje u kasi (Podešavanja, Integracije, Odlazna obaveštenja) i šalje događaje racun.fiskalizovan, racun.greska, refundacija.izvrsena i storno.izvrsen sa zaglavljima X-Tezga-Event, X-Tezga-Delivery i X-Tezga-Signature. Handler radi tri stvari, tim redom: proveri potpis nad sirovim telom, proveri da li je X-Tezga-Delivery već obrađen, upiše ishod u porudžbinu.
// app/api/tezga-webhook/route.js (server)
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(req) {
const sirovo = await req.text();
const ocekivano = "sha256=" +
createHmac("sha256", process.env.TEZGA_WEBHOOK_SECRET).update(sirovo).digest("hex");
const primljeno = req.headers.get("x-tezga-signature") || "";
if (primljeno.length !== ocekivano.length ||
!timingSafeEqual(Buffer.from(primljeno), Buffer.from(ocekivano))) {
return new Response("potpis", { status: 401 });
}
const isporuka = req.headers.get("x-tezga-delivery");
if (await db.isporuke.postoji(isporuka)) return new Response("ok", { status: 200 });
const dogadjaj = req.headers.get("x-tezga-event");
const telo = JSON.parse(sirovo);
await db.transaction(async (tx) => {
await tx.isporuke.upisi(isporuka, dogadjaj);
await tx.porudzbine.upisiFiskal(telo, dogadjaj); // broj računa ili tekst greške
});
return new Response("ok", { status: 200 });
}
Ako ti u događaju treba više od onoga što stigne, pozovi GET /api/integrations/documents/{id} ili GET /api/integrations/documents?externalId=: vraćaju stanje dokumenta, PFR broj i javniPdfUrl, koji možeš da prikažeš kupcu na strani porudžbine. Događaj racun.greska je jednako važan kao uspeh: on ti kaže da je kasa odustala od ponavljanja i da porudžbina traži čoveka.
Gde žive tajne i kako se čuvaju?
U Next.js projektu postoje dve vrste promenljivih okruženja: one sa prefiksom NEXT_PUBLIC_, koje se ugrađuju u klijentski bundle, i sve ostale, koje vide samo serverski delovi. Sve tajne iz ove arhitekture su u drugoj grupi.
TEZGA_API_KEY: ključ kase, obliktzg_plus 48 heksadecimalnih znakova, vidi se samo jednom pri pravljenju, kasa ga čuva kao heš. Jedan ključ po sistemu po okruženju (do 20 po firmi), rotacija na 90 dana, opciono vezan za IP servera kad je adresa stalna.TEZGA_WEBHOOK_SECRET: tajna za proveruX-Tezga-Signaturena dolaznim webhookovima.- Tajna procesora za proveru callbacka; ime zavisi od procesora sa kojim klijent radi (u Srbiji su to domaći procesori i banke, uslove proveri kod svoje banke).
Tri pravila koja sprovodimo na svakom projektu. Prvo, u repozitorijumu je samo .env.example sa imenima, bez vrednosti. Drugo, testni i produkcioni ključ su u različitim okruženjima hostinga pod istim imenom; okruženje je osobina naloga u kasi, ne ključa, pa kod nema granu za test. Treće, pre svakog puštanja automatski test pretraži klijentski bundle za niz tzg_ i pada ako ga nađe. Kako se dobija testni nalog i kako izgleda prelazak u produkciju opisano je u tekstu sandbox za fiskalizaciju.
Kako se prate greške pre nego što ih kupac prijavi?
Porudžbina u ovoj arhitekturi ima stanje fiskalizacije koje se vidi u administraciji shopa, ne samo u kasi. To je namerno: osoba koja pakuje pakete ne mora da otvara kasu da bi znala da li je račun izdat.
Ekran praćenja pokazuje stanje fiskalizacije i HTTP kod po porudžbini; 202 je čekanje, a ne greška, dok 400 traži ispravku i ponovni pokušaj.
Praćenje se sastoji od tri sloja. Prvi je kolona stanja i koda uz porudžbinu, iz radnika i iz webhooka. Drugi je uzbuna: mejl ili poruka vlasniku kad greška stoji duže od 15 minuta, kad stigne 402 (kredit u paketu „Po računu” potrošen; posle toga prolazi još 100 računa, pa API vraća 402) ili kad 401 ukaže na istekao ključ. Treći je dnevni presek: broj porudžbina sa stanjem „plaćeno” bez dokumenta mora da bude nula na kraju dana. Za knjigovođu izvor istine ostaje kasa, čiji izveštaji idu na raspored mejlom, sa CSV izvozom; tabela u shopu je operativni pogled, ne knjiga.
Refundacija ide istim redom čekanja: zadatak „refundiraj” zove POST /api/integrations/refund sa vrsta: refundacija, originalom po externalId i stavkama za delimičan povraćaj; kad PFR padne, stiže 202 i ponovljeni zahtev ne pravi dupli dokument. Rok od 14 dana za odustanak kupca na daljinu iz Zakona o zaštiti potrošača (Sl. glasnik RS 35/2026) znači da je refundacija redovna operacija, pa i ona ima svoje stanje i svoj kod u tabeli.
Šta znači „shop sa fiskalizacijom ključ u ruke”?
Go Simple gradi headless shopove po ovoj arhitekturi i predaje ih kao celinu: front na Next.js, route handleri, red čekanja, veza sa Tezga eKasom, webhook, ekran praćenja, testovi i dokumentacija za klijenta. Klijent dobija shop koji od prve porudžbine izdaje fiskalne račune, šalje ih kupcima mejlom i ima vidljivo stanje svakog dokumenta. U ponudu ulazi i predračun sa NBS IPS QR kodom za kupce koji plaćaju uplatom na račun (pravila IPS sistema su na ips.nbs.rs), pa se fiskalni račun izdaje sam kad uplata stigne na izvod, kao i SEF tok za B2B kupce sa PIB-om, po pravilima Sistema e-faktura.
Šta ne ulazi u ponudu, da bude jasno unapred: prodaja na licu mesta u fizičkoj radnji, jer za nju Zakon o fiskalizaciji traži L-PFR u objektu, dok je V-PFR dozvoljen za online prodaju i prodaju na daljinu (tumačenja su na sajtu Poreske uprave); sopstveni L-PFR Tezge je u pripremi bez obećanog datuma. Ne ulaze ni poreske odluke: koje poreske oznake nose artikli i koji tok koriste B2B računi odlučuje knjigovođa klijenta, a mi to unosimo iz GET /api/integrations/tax-labels. Ostale tekstove za agencije, uključujući 10 grešaka pri fiskalizaciji web shopa, imate na našem blogu, a pregled kase iz ugla agencije na strani fiskalna kasa za klijente agencije. Iskustvo iz prve ruke o tome kako je API kase nastao i šta je autor naučio praveći ga ima u tekstu fiskalizacija iz sopstvene aplikacije, a širi vodič kroz integracije na Treku, Tezga eKasa API, integracije i automatizacije.
Izvori: Zakon o fiskalizaciji (Sl. glasnik RS 153/2020, 96/2021, 138/2022); Pravilnik o vrstama fiskalnih računa, tipovima transakcija, načinima plaćanja; Tehničko uputstvo za ESIR i tumačenja Poreske uprave za daljinsku prodaju (purs.gov.rs); Zakon o zaštiti potrošača (Sl. glasnik RS 35/2026); Sistem e-faktura (efaktura.mfin.gov.rs); NBS IPS QR (ips.nbs.rs); uputstvo za integratore i OpenAPI opis Tezga eKase (odobreni ESIR, ИБ 1597). Tekst nije pravni savet; poreske odluke klijenta proverite sa njegovim knjigovođom.
Česta pitanja
Za shop do nekoliko stotina porudžbina dnevno dovoljna je tabela zadataka u istoj bazi, sa jedinstvenim ključem po porudžbini i cron rutom koja je obrađuje na minut. Servis reda ima smisla kad shop radi na više instanci i kad želiš da obrada bude odmah, a ne na sledeći minut. Arhitektura je ista u oba slučaja.
Za upis porudžbine može. Za callback procesora ne, jer procesor zove javnu HTTP adresu, a to je route handler. Za poziv kase ne, jer poziv ide iz radnika, ne iz zahteva korisnika. Server action je pogodan za dugme „Ponovi” u administraciji, koje samo pomera zadatak na sledeći pokušaj.
Potvrdu porudžbine i poruku da račun stiže mejlom. Ne prikazuj mu sopstveni PDF, jer po Tehničkom uputstvu za ESIR svaki dokument koji nije fiskalni račun nosi napomenu da nije fiskalni. Kad stigne webhook, strana porudžbine dobija link ka javniPdfUrl, a kupac je već dobio mejl od kase.
Zadatak „fiskalizuj” se ne upisuje pri naplati, jer naplate nema, nego pri isporuci, kad kurir potvrdi predaju. Način plaćanja je „Prenos na račun”, jer novac stiže preko kurira na račun, a ne gotovina. Ako klijent hoće račun pri slanju paketa, to je odluka koju treba proveriti sa knjigovođom.
Radnik zadatke sa 401 šalje čoveku i šalje uzbunu, a ne ponavlja ih. Porudžbine ostaju sa stanjem „plaćeno, čeka fiskalizaciju”. Kad se u okruženje upiše nov ključ, dugme „Ponovi” ili ponovno pokretanje radnika obradi sve zaostale zadatke, sa istim externalId, pa nema duplih dokumenata.
Radi, sa jednom razlikom: zadatak pri porudžbini šalje tipDokumenta: predracun sa obaveznim pozivNaBroj, a fiskalni račun ne izdaje shop nego kasa, kad uplata stigne na izvod i upari se po pozivu na broj. Shop o tome saznaje iz webhooka racun.fiskalizovan, kao i za svaki drugi račun.
Plaća se paket firme klijenta, ne integracija. Za shopove sa promenljivim obimom pogodan je paket „Po računu” sa 4 RSD po fiskalnom računu i refundaciji, dopuna unapred od 2.000 do 20.000 RSD, kredit važi 12 meseci; za stalan promet mesečni paketi Start, Posao ili Biznis, a SEF tok je u paketu Biznis. Cene su neto, jer Go Simple nije u sistemu PDV-a.
Ako planirate headless shop koji od prve porudžbine izdaje fiskalne račune, pogledajte API za fiskalizaciju, otvorite nalog na strani probaj fiskalnu kasu i pišite nam na [email protected] za ponudu „shop sa fiskalizacijom ključ u ruke”.
Šta nudi Tezga eKasa
- REST API za fiskalizaciju koji zove radnik, ne pregledač
- Idempotentnost po externalId: jedna porudžbina, jedan račun
- Webhook sa stanjem dokumenta nazad u shop
- V-PFR fiskalizacija bez uređaja, sa automatskim ponavljanjem
Pročitaj još
Prodaja bez web shopa: naplata linkom, IPS QR i narudžbine iz poruka → 10 grešaka agencija pri fiskalizaciji web shopa i kako ih ispraviti → Sandbox za fiskalizaciju: od testnog naloga do produkcije za jedan dan →Probaj Tezga eKasa besplatno
Fiskalni računi i e-fakture za uslužne delatnosti - kasa koja ne komplikuje.
Korisni vodiči za male firme - jednom mesečno, bez spama.