eFiskalizacija.cloud

Webhook Notifikacije

eFiskalizacija.cloud automatski šalje POST notifikacije na vaš URL nakon svake fiskalizacije — bilo uspešne ili neuspele.

Aktiviranje webhooks-a

Webhooks se aktiviraju u admin panelu:

  1. Ulogujte se na admin panel
  2. Idite na Podešavanja → Webhooks
  3. Uključite prekidač Webhook notifikacije uključene
  4. Unesite vaš HTTPS URL endpoint (npr. https://vasadomena.rs/webhook?key=vas_kljuc)
  5. Izaberite evente koje želite primati (invoice.fiscalized i/ili invoice.failed)
  6. Kliknite Sačuvaj podešavanja
  7. Opciono: kliknite Pošalji test da proverite da li vaš endpoint prima zahteve
Napomena: URL mora počinjati sa https://. HTTP nije dozvoljen iz bezbednosnih razloga.

Dostupni eventi

EventOpisKada se šalje
invoice.fiscalized Uspešna fiskalizacija Kada VSDC vrati uspešan odgovor
invoice.failed Neuspela fiskalizacija Kada VSDC vrati grešku ili timeout

Payload struktura

Webhook payload je JSON objekt sa top-level poljima event, timestamp i data. Sva polja sa podacima o računu nalaze se unutar data objekta.

Uspešna fiskalizacija (invoice.fiscalized)

{
  "event": "invoice.fiscalized",
  "timestamp": "2026-01-25T14:30:00+01:00",
  "data": {
    "invoice_id": 12345,
    "invoice_number": "SHOP-2026-001",
    "success": true,
    "pfr_broj": "AB12CD34-Ef5Gh6i7-101",
    "ukupan_iznos": "11900.00",
    "datum_izdavanja": "2026-01-25 14:30:00",
    "tip_racuna": "P",
    "tip_transakcije": "P",
    "nacin_placanja": "K",
    "kupac": {
      "ime": "Petar Petrović",
      "pib": "123456789",
      "adresa": "Knez Mihailova 10, Beograd",
      "email": "petar@example.com"
    },
    "stavke": [
      {
        "naziv": "Laptop HP ProBook",
        "sifra_artikla": "HP-PB-450",
        "kolicina": "1.00",
        "jedinicna_cena": "9916.67",
        "pdv_stopa": "20.00",
        "ukupan_iznos": "11900.00",
        "jedinica_mere": "kom",
        "rabat_procenat": "0.00"
      }
    ]
  }
}
Napomena o kodovima: Polja tip_racuna, tip_transakcije i nacin_placanja sadrže jednoslovne VSDC kodove:
  • tip_racuna: P = Promet, A = Avans, F = Proforma, K = Kopija, T = Obuka
  • tip_transakcije: P = Prodaja (Sale), R = Refundacija (Refund)
  • nacin_placanja: G = Gotovina, K = Kartica, V = Virman, U = Vaučer, I = Instant, O = Drugo bezgotovinsko
Napomena o slovu "P": I tip_racuna i tip_transakcije koriste slovo "P" za standardnu prodaju — ali to su dva potpuno različita polja. tip_racuna: "P" označava Promet (vrsta računa), a tip_transakcije: "P" označava Prodaju (smer transakcije). Podudaranje slova je slučajno i potiče iz VSDC specifikacije. Napomena o kupcu: Objekat kupac se šalje samo ako su podaci o kupcu navedeni pri fiskalizaciji. Unutar objekta, prazna polja se izostavljaju — npr. ako kupac nema JMBG, polje jmbg neće biti prisutno.

Neuspela fiskalizacija (invoice.failed)

{
  "event": "invoice.failed",
  "timestamp": "2026-01-25T14:30:00+01:00",
  "data": {
    "invoice_id": 12346,
    "invoice_number": "SHOP-2026-002",
    "success": false,
    "error_message": "VSDC timeout — pokušajte ponovo"
  }
}

// Kada greška nastane pre kreiranja zapisa (npr. validaciona greška):
{
  "event": "invoice.failed",
  "timestamp": "2026-01-25T14:30:00+01:00",
  "data": {
    "invoice_id": null,
    "invoice_number": "SHOP-2026-003",
    "success": false,
    "error_message": "Stavke su obavezne"
  }
}
Napomena: Polje invoice_id je null kada greška nastane pre nego što je zapis kreiran u bazi (npr. validaciona greška u zahtevu). Uvek proverite vrednost pre korišćenja.

Referenca polja

PoljeTipNullableOpis
eventstringNeUvek prisutno: invoice.fiscalized ili invoice.failed
timestampstring (ISO 8601)NeVreme kreiranja webhook-a (sa vremenskom zonom)
data.invoice_idintegerDaInterni ID računa; null ako greška nastane pre kreiranja zapisa
data.invoice_numberstringDaBroj računa koji ste prosledili u zahtevu
data.successbooleanNetrue = uspešno, false = neuspelo
data.pfr_brojstringDaPFR identifikator dodeljen od VSDC-a (samo kod uspešne)
data.ukupan_iznosstring (decimal)DaUkupan iznos u RSD (samo kod uspešne)
data.datum_izdavanjastringDaDatum i vreme fiskalizacije (samo kod uspešne)
data.tip_racunastringDaJednoslovni VSDC kod tipa računa (samo kod uspešne)
data.tip_transakcijestringDaJednoslovni VSDC kod transakcije (samo kod uspešne)
data.nacin_placanjastringDaJednoslovni VSDC kod načina plaćanja (samo kod uspešne)
data.kupacobjectDaPodaci kupca ako su navedeni; prazna polja unutar objekta se izostavljaju (samo kod uspešne)
data.stavkearrayDaLista stavki računa (samo kod uspešne)
data.error_messagestringDaOpis greške (samo kod neuspele)

HTTP headeri

Svaki webhook zahtev sadrži sledeće headere:

HeaderVrednostOpis
Content-Type application/json; charset=utf-8 JSON payload sa UTF-8 kodiranjem
X-Webhook-Event invoice.fiscalized ili invoice.failed Tip eventa (isti kao event polje u payload-u)
X-Webhook-Delivery-Attempt 1, 2, ili 3 Redni broj pokušaja isporuke
User-Agent eFiskalizacija-Webhook/1.3.0 Identifikacija servisa sa trenutnom verzijom

Očekivani odgovor

Vaš endpoint treba da vrati HTTP status kod 2xx (200–299) da bi isporuka bila označena kao uspešna.

Važno: Ako vaš endpoint vrati 4xx status (osim 429), isporuka će biti odmah označena kao trajno neuspela bez daljeg pokušaja.

Retry logika

PokušajČekanje pre ovog pokušaja
1Odmah
25 sekundi
325 sekundi

Maksimalno 3 pokušaja. Pauze između pokušaja su 5s i 25s (backoff formula: 5 × 5(pokušaj−1)). Treći pokušaj je terminalan — ako ne uspe, isporuka se trajno označava kao neuspela. Četvrtog pokušaja nema, pa se vrednost 125s iz formule nikad ne primenjuje.

Zaštita endpointa

Sistem ne generiše HMAC potpis webhook-a, pa zaštitu endpointa implementirate sami. Preporučujemo jedan od sledećih pristupa:

1. Query parametar sa tajnim ključem (preporučeno)

Dodajte tajni ključ u URL kao query parametar:

https://vasadomena.rs/webhook?key=vas_tajni_kljuc_123

Zatim u vašem endpointu proverite vrednost pre obrade payload-a:

<?php
// getenv() vraća false ako promenljiva nije postavljena — koristite ?: ''
$expectedSecret = getenv('WEBHOOK_SECRET') ?: '';
$receivedSecret = $_GET['key'] ?? '';

// Odbijte zahtev odmah ako secret nije konfigurisan
if ($expectedSecret === '') {
    http_response_code(500);
    exit;
}

if (!hash_equals($expectedSecret, $receivedSecret)) {
    http_response_code(403);
    exit('Forbidden');
}

2. IP whitelist

Možete ograničiti pristup endpointu samo sa IP adresa naših servera. IP adrese možete zatražiti putem kontakt forme na sajtu.

Primeri implementacije

PHP Native
<?php
declare(strict_types=1);

// 1. Verifikacija tajnog ključa (query parametar)
// getenv() vraća false ako promenljiva nije postavljena — koristite ?: ''
$expectedSecret = getenv('WEBHOOK_SECRET') ?: '';
$receivedSecret = $_GET['key'] ?? '';

// Odbijte zahtev odmah ako secret nije konfigurisan
if ($expectedSecret === '') {
    http_response_code(500);
    exit;
}

if (!hash_equals($expectedSecret, $receivedSecret)) {
    http_response_code(403);
    exit;
}

// 2. Parsiranje payload-a
$payload = json_decode(file_get_contents('php://input'), true);

if (json_last_error() !== JSON_ERROR_NONE) {
    http_response_code(400);
    exit;
}

// 3. Obrada eventa
// Svi podaci o računu nalaze se unutar $payload['data']
$event = $payload['event'] ?? '';
$data  = $payload['data'] ?? [];

switch ($event) {
    case 'invoice.fiscalized':
        // Ažuriraj narudžbinu kao fiskalizovanu
        updateOrder($data['invoice_number'], [
            'pfr_broj'        => $data['pfr_broj'],
            'ukupan_iznos'    => $data['ukupan_iznos'],
            'datum_izdavanja' => $data['datum_izdavanja'],
        ]);

        // Pošalji email kupcu — adresa je u data.kupac.email (ako postoji)
        $kupacEmail = $data['kupac']['email'] ?? null;
        if ($kupacEmail) {
            sendFiscalReceiptEmail($kupacEmail, $data['pfr_broj']);
        }
        break;

    case 'invoice.failed':
        // Logiraj grešku za ručnu obradu
        logFiscalizationError($data['invoice_number'], $data['error_message']);

        // Obavesti administratora
        notifyAdmin('Fiskalizacija neuspela', $data);
        break;
}

// 4. Uspešan odgovor
http_response_code(200);
echo json_encode(['status' => 'ok']);
Python Flask
from flask import Flask, request, jsonify
import hmac
import os

app = Flask(__name__)

@app.route('/webhook', methods=['POST'])
def webhook():
    # 1. Verifikacija tajnog ključa (query parametar)
    # os.environ.get sa default '' — nikad None, nema TypeError u compare_digest
    expected_secret = os.environ.get('WEBHOOK_SECRET', '')
    received_secret = request.args.get('key', '')

    # Odbijte zahtev ako secret nije konfigurisan
    if not expected_secret:
        return jsonify({'error': 'Server misconfigured'}), 500

    if not hmac.compare_digest(expected_secret, received_secret):
        return jsonify({'error': 'Forbidden'}), 403

    # 2. Parsiranje payload-a
    payload = request.get_json()

    if not payload:
        return jsonify({'error': 'Bad Request'}), 400

    # 3. Obrada eventa
    # Svi podaci o računu nalaze se unutar payload['data']
    event = payload.get('event')
    data  = payload.get('data', {})

    if event == 'invoice.fiscalized':
        # Ažuriraj narudžbinu
        update_order(data['invoice_number'], {
            'pfr_broj':        data['pfr_broj'],
            'ukupan_iznos':    data['ukupan_iznos'],
            'datum_izdavanja': data['datum_izdavanja'],
        })

        # Email kupcu — adresa je u data['kupac']['email'] (ako postoji)
        kupac_email = data.get('kupac', {}).get('email')
        if kupac_email:
            send_fiscal_receipt_email(kupac_email, data['pfr_broj'])

    elif event == 'invoice.failed':
        # Logiraj grešku
        log_error(data.get('invoice_number'), data.get('error_message'))

    return jsonify({'status': 'ok'}), 200

if __name__ == '__main__':
    app.run()
Node.js Express
const express = require('express');
const crypto  = require('crypto');

const app = express();
app.use(express.json());

// timingSafeEqual baca RangeError ako bufferi nisu iste dužine.
// Rešenje: hešujemo oba niza na SHA-256 pre poređenja (uvek 32 bajta).
function safeCompare(a, b) {
    const hashA = crypto.createHash('sha256').update(a).digest();
    const hashB = crypto.createHash('sha256').update(b).digest();
    return crypto.timingSafeEqual(hashA, hashB);
}

app.post('/webhook', (req, res) => {
    // 1. Verifikacija tajnog ključa (query parametar)
    const expectedSecret = process.env.WEBHOOK_SECRET || '';
    const receivedSecret = req.query.key  || '';

    // Odbijte zahtev ako secret nije konfigurisan
    if (!expectedSecret) {
        return res.status(500).json({ error: 'Server misconfigured' });
    }

    if (!safeCompare(expectedSecret, receivedSecret)) {
        return res.status(403).json({ error: 'Forbidden' });
    }

    // 2. Parsiranje payload-a
    // Svi podaci o računu nalaze se unutar req.body.data
    const { event, data } = req.body;

    if (!event || !data) {
        return res.status(400).json({ error: 'Bad Request' });
    }

    // 3. Obrada eventa
    switch (event) {
        case 'invoice.fiscalized':
            // Ažuriraj narudžbinu
            updateOrder(data.invoice_number, {
                pfrBroj:        data.pfr_broj,
                ukupanIznos:    data.ukupan_iznos,
                datumIzdavanja: data.datum_izdavanja,
            });

            // Email kupcu — adresa je u data.kupac?.email (ako postoji)
            if (data.kupac?.email) {
                sendFiscalReceiptEmail(data.kupac.email, data.pfr_broj);
            }
            break;

        case 'invoice.failed':
            // Logiraj grešku
            logError(data.invoice_number, data.error_message);
            break;
    }

    res.json({ status: 'ok' });
});

app.listen(3000);
C# ASP.NET Core
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;

[ApiController]
[Route("webhook")]
public class WebhookController : ControllerBase
{
    private readonly IConfiguration _config;
    private readonly IOrderService _orderService;

    public WebhookController(IConfiguration config, IOrderService orderService)
    {
        _config = config;
        _orderService = orderService;
    }

    [HttpPost]
    public IActionResult HandleWebhook([FromQuery] string key, [FromBody] WebhookPayload payload)
    {
        // 1. Verifikacija tajnog ključa (query parametar)
        var expectedSecret = _config["WEBHOOK_SECRET"] ?? "";
        if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expectedSecret),
            Encoding.UTF8.GetBytes(key ?? "")))
        {
            return Forbid();
        }

        // 2. Obrada eventa
        // Svi podaci o računu nalaze se unutar payload.Data
        switch (payload.Event)
        {
            case "invoice.fiscalized":
                _orderService.UpdateOrder(payload.Data.InvoiceNumber, new OrderUpdate
                {
                    PfrBroj        = payload.Data.PfrBroj,
                    UkupanIznos    = payload.Data.UkupanIznos,
                    DatumIzdavanja = payload.Data.DatumIzdavanja,
                });

                // Email kupcu — adresa je u Data.Kupac?.Email (ako postoji)
                if (payload.Data.Kupac?.Email != null)
                {
                    _orderService.SendFiscalReceiptEmail(payload.Data.Kupac.Email, payload.Data.PfrBroj);
                }
                break;

            case "invoice.failed":
                _orderService.LogError(payload.Data.InvoiceNumber, payload.Data.ErrorMessage);
                break;
        }

        return Ok(new { status = "ok" });
    }
}

// Modeli payload-a (sva polja su u Data objektu)
public record WebhookPayload(
    [property: JsonPropertyName("event")]     string Event,
    [property: JsonPropertyName("timestamp")] string Timestamp,
    [property: JsonPropertyName("data")]      WebhookData Data
);

public record WebhookData(
    [property: JsonPropertyName("invoice_id")]     int? InvoiceId,
    [property: JsonPropertyName("invoice_number")] string? InvoiceNumber,
    [property: JsonPropertyName("success")]        bool Success,
    [property: JsonPropertyName("pfr_broj")]       string? PfrBroj,
    [property: JsonPropertyName("ukupan_iznos")]   string? UkupanIznos,
    [property: JsonPropertyName("datum_izdavanja")]string? DatumIzdavanja,
    [property: JsonPropertyName("tip_racuna")]     string? TipRacuna,
    [property: JsonPropertyName("tip_transakcije")]string? TipTransakcije,
    [property: JsonPropertyName("nacin_placanja")] string? NacinPlacanja,
    [property: JsonPropertyName("kupac")]          WebhookKupac? Kupac,
    [property: JsonPropertyName("error_message")]  string? ErrorMessage
);

public record WebhookKupac(
    [property: JsonPropertyName("ime")]    string? Ime,
    [property: JsonPropertyName("pib")]    string? Pib,
    [property: JsonPropertyName("jmbg")]   string? Jmbg,
    [property: JsonPropertyName("adresa")] string? Adresa,
    [property: JsonPropertyName("email")]  string? Email
);

Testiranje

U admin panelu možete:

  1. Poslati test webhook — šalje simulirani payload na vaš URL i beleži rezultat u istoriju
  2. Pregledati istoriju isporuka — vidite poslednjih 20 isporuka sa statusom, HTTP kodom i trajanjem
  3. Obrisati istoriju — briše sve zapise osim onih koji čekaju isporuku

Česta pitanja

Koliko dugo se čuva istorija isporuka?

Zapisi se čuvaju u bazi bez automatskog brisanja. Admin panel prikazuje poslednjih 20 isporuka. Sve zapise možete ukloniti ručno klikom na Obriši istoriju.

Šta ako moj server ne odgovori?

Pokušaćemo još 2 puta sa rastućim čekanjem (5s pre drugog, 25s pre trećeg pokušaja). Ukupno 3 pokušaja. Ako ni treći ne uspe, isporuka se trajno označava kao neuspela.

Da li webhooks imaju HMAC potpis?

Ne. Sistem ne generiše HMAC potpis za webhook zahteve. Zaštitu endpointa implementirate sami — npr. tajnim ključem u URL-u ili IP whitelistom.

Da li mogu primati webhooks na HTTP (ne HTTPS)?

Ne. Iz bezbednosnih razloga, webhook URL mora počinjati sa https://.

Kako da testiram lokalno?

Koristite servise kao što su ngrok ili webhook.site za izlaganje lokalnog servera na internet.