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:
- Ulogujte se na admin panel
- Idite na Podešavanja → Webhooks
- Uključite prekidač Webhook notifikacije uključene
- Unesite vaš HTTPS URL endpoint (npr.
https://vasadomena.rs/webhook?key=vas_kljuc) - Izaberite evente koje želite primati (
invoice.fiscalizedi/iliinvoice.failed) - Kliknite Sačuvaj podešavanja
- Opciono: kliknite Pošalji test da proverite da li vaš endpoint prima zahteve
https://. HTTP nije dozvoljen iz bezbednosnih razloga.
Dostupni eventi
| Event | Opis | Kada 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"
}
]
}
}
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
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"
}
}
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
| Polje | Tip | Nullable | Opis |
|---|---|---|---|
event | string | Ne | Uvek prisutno: invoice.fiscalized ili invoice.failed |
timestamp | string (ISO 8601) | Ne | Vreme kreiranja webhook-a (sa vremenskom zonom) |
data.invoice_id | integer | Da | Interni ID računa; null ako greška nastane pre kreiranja zapisa |
data.invoice_number | string | Da | Broj računa koji ste prosledili u zahtevu |
data.success | boolean | Ne | true = uspešno, false = neuspelo |
data.pfr_broj | string | Da | PFR identifikator dodeljen od VSDC-a (samo kod uspešne) |
data.ukupan_iznos | string (decimal) | Da | Ukupan iznos u RSD (samo kod uspešne) |
data.datum_izdavanja | string | Da | Datum i vreme fiskalizacije (samo kod uspešne) |
data.tip_racuna | string | Da | Jednoslovni VSDC kod tipa računa (samo kod uspešne) |
data.tip_transakcije | string | Da | Jednoslovni VSDC kod transakcije (samo kod uspešne) |
data.nacin_placanja | string | Da | Jednoslovni VSDC kod načina plaćanja (samo kod uspešne) |
data.kupac | object | Da | Podaci kupca ako su navedeni; prazna polja unutar objekta se izostavljaju (samo kod uspešne) |
data.stavke | array | Da | Lista stavki računa (samo kod uspešne) |
data.error_message | string | Da | Opis greške (samo kod neuspele) |
HTTP headeri
Svaki webhook zahtev sadrži sledeće headere:
| Header | Vrednost | Opis |
|---|---|---|
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.
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 |
|---|---|
| 1 | Odmah |
| 2 | 5 sekundi |
| 3 | 25 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
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']);
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()
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);
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:
- Poslati test webhook — šalje simulirani payload na vaš URL i beleži rezultat u istoriju
- Pregledati istoriju isporuka — vidite poslednjih 20 isporuka sa statusom, HTTP kodom i trajanjem
- 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.