Integracija preko API-ja · 13 min čitanja

Fiskalizacija za custom sajt: Next.js, Laravel, WordPress, Webflow

Custom sajt ne treba plugin da bi izdavao fiskalne račune: dovoljan je jedan serverski poziv ka Tezga API-ju posle uspešne naplate. Tekst pokazuje gde se poziv stavlja u Next.js, Laravelu, WordPressu bez WooCommerce, Webflow-u i Frameru, sa isečcima koda, pravilima za tajne i zaštitom od duplog računa.

Custom sajt izdaje fiskalni račun tako što tvoj server, posle potvrde naplate, pošalje jedan POST zahtev na Tezga API sa stavkama, kupcem i načinom plaćanja. Tezga račun fiskalizuje preko V-PFR-a Poreske uprave i pošalje ga kupcu mejlom. Nije potreban plugin, štampač ni hardver, samo ključ koji nikad ne napušta server.

Ukratko
  • Poziv ka Tezga API-ju ide isključivo sa servera (route handler, job, hook, n8n), nikad iz JavaScript-a u pregledaču.
  • Jedna porudžbina nosi jedan externalId; koliko god puta sajt ponovi zahtev, nastaje tačno jedan račun.
  • Račun se šalje tek kad je naplata potvrđena od procesora ili kad je uplata viđena na izvodu, ne kad kupac klikne „Naruči”.
  • Odgovor 202 znači da je V-PFR trenutno nedostupan i da Tezga sama ponavlja; sajt ne pravi novi zahtev.
  • Webflow i Framer nemaju server, pa formu šalju na n8n webhook, a Tezga izda predračun sa IPS QR kodom i račun kad uplata legne.

Gde se zove API i zašto nikad iz pregledača?

API ključ Tezga eKase ide u zaglavlje X-Tezga-Api-Key, počinje sa tzg_ i nosi 48 heksadecimalnih znakova. Ko ima ključ, može da izdaje fiskalne račune u ime tvoje firme. Ako ključ staviš u JavaScript koji se izvršava u pregledaču kupca, svaki posetilac ga vidi u alatu za programere i može da ga zloupotrebi. Zato važi jedno pravilo bez izuzetka: poziv ka https://pos.narbiz.com/api/integrations/order ide sa servera koji ti kontrolišeš.

Gde živi API ključ: samo na serveru Pregledač kupca fetch sa ključem: NE Server sajta ili n8n ključ u okruženju: DA HTTPS + ključ Tezga eKasa ključ čuva kao heš V-PFR Poreske uprave vraća broj računa porudžbina bez ključa Dodatna zaštita: vezivanje ključa za IP servera, HMAC potpis tela (X-Tezga-Signature), rotacija na 90 dana, zaseban ključ po sajtu (do 20 po firmi).

Ključ živi samo na serveru sajta ili u n8n-u; pregledač kupca šalje porudžbinu serveru, a server zove Tezgu.

Ključ čuvaš kao promenljivu okruženja (TEZGA_API_KEY u .env, u tajnama hosting platforme ili u n8n credentialu), a ne u kodu i ne u git repozitorijumu. Napravi zaseban ključ za svaki sajt, jer možeš da ih imaš do 20 po firmi i jer se rotiraju na 90 dana; kad rotiraš ključ jednog sajta, drugi nastavljaju da rade. Opciono možeš da vežeš ključ za IP adresu servera i da uključiš HMAC potpis tela, pa zaglavlje X-Tezga-Signature: sha256=... dokazuje da telo nije menjano u prenosu. Sve opcije ključa su opisane na strani API za fiskalizaciju i u OpenAPI opisu.

Kako izgleda ceo tok od kupca do Poreske uprave?

Redosled je važniji od tehnologije. Račun se po Zakonu o fiskalizaciji izdaje u trenutku prometa, a promet nastaje isporukom ili naplatom, šta pre nastupi. Za online prodaju sa plaćanjem karticom to je trenutak kad procesor potvrdi naplatu; za uplatu na račun to je trenutak kad uplata legne; za pouzeće je to isporuka, kako je objašnjeno u tekstu fiskalna kasa i pouzeće. Sajt zato ne zove Tezgu na klik „Naruči”, nego na potvrdu naplate.

Sekvenca: kupac, sajt, procesor, Tezga, Poreska uprava, mejl Kupac Sajt (server) Procesor Tezga V-PFR Mejl 1. porudžbina 2. zahtev za naplatu 3. naplata potvrđena 4. POST order, externalId = broj porudžbine 5. fiskalizacija 6. PFR broj računa 7. račun sa QR kodom kupcu 8. 200 ili webhook racun.fiskalizovan

Sajt zove Tezgu tek posle koraka 3, kad je naplata potvrđena; kupac dobija račun mejlom u koraku 7, a sajt broj računa u koraku 8.

Korak 4 je jedini koji ti programiraš. Sve posle njega radi Tezga: fiskalizaciju preko V-PFR-a, slanje mejla sa PDF-om i QR kodom ako je posaljiMejl uključen, i odlazni webhook racun.fiskalizovan ako ga podesiš. Odgovor na korak 4 je 200 sa podacima dokumenta, 202 ako je PFR privremeno nedostupan (Tezga sama ponavlja), 400 za loše telo, 401 za loš ključ, 402 kad je kredit u paketu „Po računu” potrošen i 429 kad je premašen broj zahteva.

Tabela: gde se poziv stavlja u svakoj tehnologiji

TehnologijaGde se zove TezgaGde živi ključKljuč idempotentnostiPonavljanje posle greške
Next.jsRoute handler ili server action, posle callbacka procesoraPromenljiva okruženja na hostinguBroj porudžbine iz bazeRed čekanja ili cron koji ponavlja porudžbine bez računa
LaravelJob u redu čekanja pokrenut iz callbacka.env i config/services.phporders.idUgrađeno ponavljanje joba sa razmakom
WordPress bez WooCommerceHook posle potvrde plaćanja, wp_remote_postwp-config.php konstantaID unosa forme ili post ID porudžbineWP-Cron zadatak koji ponavlja
Webflown8n webhook koji prima formun8n credentialID pošiljke formen8n ponavljanje čvora
Framern8n webhook koji prima formun8n credentialID pošiljke formen8n ponavljanje čvora

Ako tvoj sajt koristi WooCommerce ili Shopify, ne treba ti ništa od ovoga: Tezga ima dvosmernu vezu koja preuzima porudžbine webhookom i povlačenjem na 30 minuta, fiskalizuje na izabrani status i upisuje broj računa nazad u porudžbinu. Za to pogledaj WooCommerce fiskalizaciju i Shopify fiskalizaciju. Ovaj tekst je za sve ostalo.

Next.js: kako route handler izdaje račun?

U Next.js aplikaciji poziv ide u route handler (app/api/.../route.js) ili u server action, jer se oba izvršavaju na serveru i vide process.env. Klijentska komponenta nikad ne vidi ključ. Uobičajen raspored: procesor pošalje callback na tvoj route handler kad je naplata uspela, handler proveri potpis procesora, upiše porudžbinu kao plaćenu i onda pozove Tezgu.

// app/api/naplata-potvrdjena/route.js  (izvršava se na serveru)
export async function POST(req) {
  const p = await req.json(); // podaci porudžbine iz tvoje baze
  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.brojPorudzbine,
        tipDokumenta: "racun",
        tok: "fiskalni",
        nacinPlacanja: "Platna kartica",
        posaljiMejl: true,
        kupac: { naziv: p.ime, email: p.email },
        stavke: p.stavke
      })
    }
  );
  if (odgovor.status === 202) {
    return Response.json({ stanje: "ceka_pfr" }); // Tezga ponavlja sama
  }
  if (!odgovor.ok) {
    throw new Error("Tezga " + odgovor.status); // upiši u dnevnik, ponovi kasnije
  }
  return Response.json(await odgovor.json());
}

Stavke iz baze moraju da imaju oblik koji API očekuje: naziv, sifra, kolicina, cenaSaPdv i poreskaStopa. Poresku oznaku uzmi jednom iz GET /api/integrations/tax-labels i sačuvaj uz artikal. Dostavu dodaj kao zasebnu stavku, jer račun mora da odgovara naplaćenom iznosu. Ako sajt radi na hostingu koji uspava funkcije, poziv ka Tezgi ne stavljaj u isti zahtev koji vraća odgovor procesoru; upiši porudžbinu kao „za fiskalizaciju” i pokreni je iz zasebne pozadinske funkcije ili crona koji svakih nekoliko minuta prođe kroz porudžbine bez broja računa. Isti externalId garantuje da ponovljeni prolaz ne izda drugi račun.

Laravel: zašto poziv ide u job u redu čekanja?

Laravel ima gotov red čekanja, pa ga iskoristi. Callback procesora samo upiše status i pošalje job; job zove Tezgu i ako dobije 5xx ili istek vremena, Laravel ga sam ponovi sa razmakom. Ključ upiši u .env kao TEZGA_API_KEY i izloži ga kroz config/services.php, da ga kod nikad ne čita direktno iz okruženja.

// app/Jobs/FiskalizujPorudzbinu.php
public int $tries = 5;
public array $backoff = [30, 120, 600, 1800];

public function handle(): void
{
    $p = $this->porudzbina;
    $odgovor = Http::withHeaders([
        'X-Tezga-Api-Key' => config('services.tezga.key'),
    ])->timeout(20)->post(
        'https://pos.narbiz.com/api/integrations/order?provider=custom',
        [
            'externalId'    => 'shop-' . $p->id,
            'tipDokumenta'  => 'racun',
            'tok'           => $p->pib ? 'fiskalni_i_sef' : 'fiskalni',
            'nacinPlacanja' => $p->nacin_placanja,
            'posaljiMejl'   => true,
            'kupac'         => ['naziv' => $p->ime, 'pib' => $p->pib, 'email' => $p->email],
            'stavke'        => $p->stavkeZaTezgu(),
        ]
    );

    if ($odgovor->status() === 202) { return; }   // PFR nedostupan, Tezga ponavlja
    if ($odgovor->status() === 400) { $this->fail(); return; } // loše telo, ne ponavljaj
    $odgovor->throw();                               // 5xx ide u ponovni pokušaj
    $p->update(['tezga_odgovor' => $odgovor->json()]);
}

Dva detalja iz koda su bitna. Prvo, 400 ne ponavljaj: telo je loše i biće loše i sledeći put; pošalji sebi obaveštenje i ispravi podatke. Drugo, ako je kupac uneo PIB, račun postaje B2B: Tezga ga označi sa Id kupca 10:PIB, a ako ste oboje u sistemu e-faktura, tok sef ili fiskalni_i_sef šalje e-fakturu u SEF. Koji tok je pravi za tvoju firmu, pitaj knjigovođu; tehnički rade sva tri. Za refundaciju napravi drugi job koji zove POST /api/integrations/refund sa istim externalId originala i, po potrebi, spiskom stavki za delimičan povraćaj.

WordPress bez WooCommerce: kako hook izdaje račun?

Sajt na WordPressu sa formom za porudžbine (bilo koji plugin za forme) i plaćanjem preko procesora ili uplatom na račun. Tezga u paketu ima WordPress integraciju za WooCommerce; kad WooCommerce nemaš, poziv pišeš sam u temi ili malom pluginu. Pravilo je isto: hook se izvršava na serveru, ključ je konstanta u wp-config.php, a poziv ide kroz wp_remote_post.

// wp-config.php
define('TEZGA_API_KEY', 'tzg_...');

// functions.php ili mali plugin; okidač je tvoj događaj potvrde plaćanja
add_action('moj_sajt_placanje_potvrdjeno', function (int $porudzbina_id) {
    if (get_post_meta($porudzbina_id, '_tezga_dokument', true)) {
        return; // već fiskalizovano, ne šalji ponovo
    }
    $telo = [
        'externalId'    => 'wp-' . $porudzbina_id,
        'tipDokumenta'  => 'racun',
        'tok'           => 'fiskalni',
        'nacinPlacanja' => get_post_meta($porudzbina_id, '_nacin_placanja', true),
        'posaljiMejl'   => true,
        'kupac'         => [
            'naziv' => get_post_meta($porudzbina_id, '_ime', true),
            'email' => get_post_meta($porudzbina_id, '_email', true),
        ],
        'stavke'        => get_post_meta($porudzbina_id, '_stavke', true),
    ];
    $odgovor = wp_remote_post(
        'https://pos.narbiz.com/api/integrations/order?provider=custom',
        [
            'timeout' => 20,
            'headers' => [
                'Content-Type'    => 'application/json',
                'X-Tezga-Api-Key' => TEZGA_API_KEY,
            ],
            'body' => wp_json_encode($telo),
        ]
    );
    $kod = wp_remote_retrieve_response_code($odgovor);
    if ($kod === 200) {
        update_post_meta($porudzbina_id, '_tezga_dokument', wp_remote_retrieve_body($odgovor));
        wp_mail(get_option('admin_email'), 'Račun izdat za porudžbinu ' . $porudzbina_id, 'Kupac je dobio račun mejlom.');
    } elseif ($kod !== 202) {
        wp_schedule_single_event(time() + 600, 'moj_sajt_placanje_potvrdjeno', [$porudzbina_id]);
    }
});

Mejl kupcu ne šalješ ti, šalje ga Tezga sa PDF-om i QR kodom; wp_mail u primeru ide vlasniku, kao obaveštenje da spakuje paket. Ako se plaćanje potvrđuje preko IPN ili callback adrese procesora, tvoj događaj moj_sajt_placanje_potvrdjeno okidaš iz te adrese, posle provere potpisa procesora. Ako kupac plaća uplatom na račun, umesto računa izdaj predracun sa obaveznim pozivNaBroj, pa kasa sama izda fiskalni račun kad uplata stigne na izvod; postupak je opisan na strani novac, banka i naplata.

Webflow i Framer: kako no-code sajt izdaje račun?

Webflow i Framer nemaju tvoj server, pa nemaju gde da sakriju ključ. Rešenje je posrednik: forma sa sajta šalje sadržaj na webhook adresu n8n-a (ili druge automatizacije), a n8n zove Tezgu. Ključ živi u n8n credentialu, sajt ga nikad ne vidi. Kako se webhook za formu uključuje, proveri u dokumentaciji svoje platforme; obe umeju da pošalju sadržaj forme na spoljnu adresu.

No-code tok: forma, n8n, predračun, uplata, račun Forma Webflow, Framer n8n webhook, ključ Predračun NBS IPS QR, mejl Uplata izvod u kasu Fiskalni račun, sam Bez servera i bez procesora: kupac plaća mobilnim bankarstvom, račun nastaje kad uplata legne. Uparivanje po pozivu na broj; bez poziva na broj API odbija predračun.

No-code sajt bez procesora: forma ide u n8n, Tezga izda predračun sa IPS QR kodom, a fiskalni račun nastaje sam kad uplata stigne na izvod.

Za sajt bez platnog procesora ovo je najjednostavniji put: n8n pošalje Tezgi predračun, kupac dobije mejl sa NBS IPS QR kodom, plati mobilnim bankarstvom, a kasa upari uplatu po pozivu na broj sa izvoda i izda fiskalni račun bez ikakvog ručnog rada. Telo koje n8n šalje:

{
  "externalId": "webflow-forma-5f31a9",
  "tipDokumenta": "predracun",
  "pozivNaBroj": "2026-000318",
  "posaljiMejl": true,
  "kupac": { "naziv": "Jelena Ilić", "email": "jelena@primer.rs" },
  "stavke": [
    { "naziv": "Onlajn kurs akvarela, 6 lekcija", "sifra": "KURS-AKV",
      "kolicina": 1, "cenaSaPdv": 7900, "poreskaStopa": "Ђ" }
  ]
}

Ako sajt ipak ima kartično plaćanje kroz procesor koji radi u Srbiji, tok je isti kao za Next.js, samo procesor šalje callback u n8n umesto u tvoj kod. Detaljna podešavanja n8n čvorova i sedam gotovih tokova ima u tekstu Tezga i n8n.

Kako se čuva idempotentnost kad procesor pošalje callback dva puta?

Platni procesori šalju callback više puta kad ne dobiju odgovor na vreme, mreža ponavlja zahteve, a korisnici osvežavaju strane. Bez zaštite bi svaki od tih ponovljenih događaja izdao novi fiskalni račun, a storno svakog viška je posao za tebe i knjigovođu. Zaštita ima dva sloja.

  1. Tvoj sloj. Pre poziva proveri da li porudžbina već ima upisan dokument (kao u WordPress primeru). Callback obradi u transakciji ili sa zaključavanjem reda porudžbine, da dva istovremena callbacka ne prođu proveru u istom trenutku.
  2. Sloj Tezge. Polje externalId (do 200 znakova) je ključ idempotentnosti: ista porudžbina se fiskalizuje tačno jednom, čak i kad dva zahteva stignu istovremeno. Zato externalId izvedi iz broja porudžbine, ne iz vremena ili nasumičnog niza.

Isto pravilo važi za refundaciju: POST /api/integrations/refund traži original po documentId ili externalId, a kad PFR padne, vraća 202 i ponovljeni zahtev ne pravi dupli dokument. Ako nekad ipak nastane greška u računu, ne pokušavaj da je „prepišeš”: izdaje se storno ili refundacija, kako je objašnjeno na strani storno i refundacija. Deset najčešćih grešaka iz agencijske prakse, uključujući i ovu, opisano je u tekstu 10 grešaka web agencija pri fiskalizaciji.

Šta kupac dobija i kako se test prebacuje u produkciju?

Kupac dobija mejl sa PDF-om fiskalnog računa i QR kodom kojim račun može da proveri na portalu Poreske uprave. Prema tumačenju Poreske uprave za daljinsku prodaju, fiskalni račun kupcu na daljinu može da se dostavi elektronski, uz saglasnost kupca, pa štampani primerak u paketu nije obavezan ako je kupac dobio elektronski; uputstva su na purs.gov.rs. Saglasnost rešavaš kvadratićem u formi porudžbine ili odredbom u uslovima kupovine. Ako želiš, iz GET /api/integrations/documents/{id} uzmi javniPdfUrl i prikaži ga kupcu na strani „Hvala na porudžbini”.

Razvoj radiš na testnom nalogu koji Go Simple otvara na zahtev: njegovi računi idu u sandbox Poreske uprave i nisu fiskalni. Okruženje je osobina naloga, ne ključa, pa prelazak u produkciju znači zamenu ključa u okruženju i ništa više u kodu. Alternativa je tip računa „Обука” na produkciji, koji nosi napomenu „ОВО НИЈЕ ФИСКАЛНИ РАЧУН”, pogodan za probu kad je nalog već pravi. Ceo postupak od testnog naloga do predaje klijentu, sa kontrolnom listom, opisan je u tekstu sandbox za fiskalizaciju, a pogled iz ugla programera koji je kasu pravio u tekstu fiskalizacija iz sopstvene aplikacije.

Pre puštanja proveri četiri stvari: (1) ključ nije u repozitorijumu ni u klijentskom bundlu, (2) poziv ide posle potvrde naplate, (3) ponovljeni callback ne pravi drugi račun, (4) 202 se ne tretira kao greška. Ako sajt prodaje i u fizičkoj radnji, za prodaju na licu mesta Zakon o fiskalizaciji traži L-PFR u objektu; V-PFR je dozvoljen za online prodaju i prodaju na daljinu, a sopstveni L-PFR Tezge je u pripremi.

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); dokumentacija SEF-a (efaktura.mfin.gov.rs); NBS IPS QR (ips.nbs.rs); uputstvo za integratore i OpenAPI opis Tezga eKase, ИБ 1597. Tekst nije pravni savet; za svoju situaciju proveri sa knjigovođom.

Česta pitanja

Mogu li da zovem Tezga API iz React komponente ako ključ prosledim kroz props?

Ne. Sve što stigne u klijentsku komponentu završi u pregledaču kupca, bez obzira na to kako je prosleđeno. Poziv ide iz route handlera, server actiona, joba ili n8n-a. Klijent šalje serveru samo porudžbinu, a server dodaje ključ i zove Tezgu.

Koji provider stavljam u adresu ako sajt nije nijedan od navedenih shopova?

Za custom sajt, Next.js, Laravel, WordPress bez WooCommerce, Webflow i Framer koristi provider=custom. Vrednosti woocommerce, shopify, prestashop, opencart, magento i bigcommerce su za shopove čiji oblik porudžbine Tezga već poznaje. Kod custom providera ti sam pripremaš stavke u obliku koji API očekuje.

Šta stavljam u nacinPlacanja za pouzeće?

„Prenos na račun”, jer novac stiže preko kurira na tvoj račun, a nije gotovina primljena u ruke. Kartica online je „Platna kartica”, uplata na račun je „Prenos na račun”, PayPal i slični servisi su „Drugo bezgotovinsko”. Tačan spisak vrednosti koje API prihvata je u OpenAPI opisu.

Da li sajt mora da čuva PDF računa?

Ne mora, Tezga čuva dokument i nudi javniPdfUrl kroz GET /api/integrations/documents/{id}. Dovoljno je da uz porudžbinu upišeš identifikator dokumenta iz odgovora. Kupac dobija PDF mejlom, a kopiju računa može da zatraži kasnije kroz POST /api/integrations/copy.

Kako se ponašam kad dobijem 429?

Sačekaj i ponovi zahtev sa istim externalId. Ograničenje broja zahteva štiti kasu od zagušenja; ponovljeni zahtev sa istim ključem idempotentnosti ne može da napravi dupli račun. U redu čekanja podesi razmak koji raste sa svakim pokušajem, kao u Laravel primeru.

Može li isti ključ za sajt i za n8n?

Može, ali nije pametno. Do 20 ključeva po firmi znači da svaki sistem dobija svoj ključ; kad se jedan rotira ili ugasi, ostali rade dalje. Ključ za sajt veži za IP servera ako ti hosting daje stalnu adresu, a ključ za n8n cloud ostavi bez vezivanja.

Koliko košta fiskalizacija sa custom sajta?

Plaća se paket kase, ne integracija. Mesečni paketi Start (1.190 RSD), Posao (1.690 RSD) i Biznis (2.390 RSD) se plaćaju na 3, 6 ili 12 meseci, a agencije i integratori mogu da koriste paket „Po računu”: 4 RSD po fiskalnom računu i refundaciji, dopuna unapred od 2.000 do 20.000 RSD, kredit važi 12 meseci. Cene su neto, jer Go Simple nije u sistemu PDV-a.

Ako gradiš ili održavaš custom sajt i želiš da fiskalizacija radi bez plugina, otvori nalog na strani probaj fiskalnu kasu, zatraži testni nalog za sandbox i pogledaj API za fiskalizaciju. Za pitanja oko integracije piši na podrska@gosimple.space.

Poveži svoj sistem sa kasom

Otvori nalog, napravi API ključ i pošalji prvi test račun. Ako zapneš, piši nam sa opisom sistema koji povezuješ.