Webhook obaveštenja
BokaPOS vam javlja kada se nešto promeni posle vašeg poziva: račun sa nepoznatim ishodom je ipak izdat, e-poruka sa računom je stigla ili nije, sertifikat ističe, licenca se promenila. Obaveštenja stižu kao potpisani HTTPS zahtevi na vašu adresu, a iste događaje možete i sami da preuzmete iz toka događaja.
Čemu služe
Fiskalizacija je sinhrona: račun ili 503 dobijate u istom zahtevu. Nekoliko stvari se ipak menja kasnije, i za njih ne morate više da proveravate API ili portal:
- operacija sa
OUTCOME_koju BokaPOS kasnije razreši uUNKNOWN FISCALIZED; - ishod slanja računa e-poštom (
DELIVEREDiliFAILED); - bezbednosni element koji je aktiviran, suspendovan, povučen ili mu ističe sertifikat;
- promena licence, od koje zavisi da li se produkcioni računi prihvataju;
- računi izdati iz drugog kanala (cloud POS u portalu, druga integracija) koje vaš ERP ili knjigovodstvo treba da vidi.
Događaji nose samo bezbedne identifikatore i zbirne podatke. Nikad ne nose identifikaciju ni podatke kupca, stavke, sirov odgovor V-PFR-a, tekst računa, e-adrese primalaca ni tajne. Webhook obaveštenja su besplatna i uključena u osnovnu licencu, u sandboxu i u produkciji.
Vrste događaja
| Tip | Kada | Polja u data |
|---|---|---|
fiscal-document.fiscalized | Bilo koja operacija dobije status FISCALIZED: prodaja, refundacija, kopija, avans, predračun ili obuka, iz API-ja, portala, toka ili kasnijeg razrešenja nepoznatog ishoda | fiscalDocumentId, taxpayerId, businessPremiseId, status, invoiceType, transactionType, clientReference, idempotencyKey, pfrInvoiceNumber, pfrSdcTime, totalAmount, verificationUrl |
fiscal-document.outcome-unknown | Operacija prvi put dobije status OUTCOME_ (jednom po operaciji, nikad ponovo) | isti identifikatori, status, failureCode; PFR polja su null |
receipt-delivery.delivered | Server za poštu je prihvatio e-poruku sa računom | receiptDeliveryId, fiscalDocumentId, taxpayerId, businessPremiseId, channel, status, attempts, deliveredAt |
receipt-delivery.failed | Slanje e-poruke je konačno propalo (nema više pokušaja) | isto, plus failureCode |
security-element.activated | Element dobije status active | securityElementId, taxpayerId, businessPremiseId, jid, status, certificateNotAfter |
security-element.suspended | Element dobije status suspended | isto |
security-element.retired | Element dobije status retired | isto |
security-element.expiring | Sertifikat aktivnog elementa uđe u period upozorenja (30 dana pre isteka), pa u kritični period (7 dana), po jednom | isto, plus expiryStatus (warning ili critical) i daysRemaining |
license.updated | BokaPOS izda ili promeni licencu organizacije | status, startsOn, endsOn, advanceModuleEnabled, emailModuleEnabled |
webhook-endpoint.test | Samo na vaš zahtev, jednoj adresi; nikad u toku događaja | webhookEndpointId |
Događaji licence pripadaju produkcionom okruženju, jer licenca ograničava samo produkciju. Rokovi od 30 i 7 dana su isti kao certificateExpiryStatus u GET / i oznake isteka u portalu. Pretplatiti se možete na sve tipove osim webhook-endpoint.test.
Oblik događaja
Telo svakog webhook zahteva i svaka stavka toka događaja imaju isti oblik, pa isti kod obrađuje i poslat i preuzet događaj. type određuje oblik objekta data, environment kaže okruženje (sandbox ili production), a timestamp je trenutak promene, ne trenutak isporuke.
{
"id": "21c0738c-e29c-480f-92a9-fb88a99dc514",
"type": "fiscal-document.fiscalized",
"timestamp": "2026-11-02T09:14:03.512+00:00",
"environment": "sandbox",
"data": {
"fiscalDocumentId": "9f8e7d6c-5b4a-4321-8765-0fedcba98761",
"taxpayerId": "3f9c2a8e-6b1d-4e5a-9c47-1d2b8e6f0a11",
"businessPremiseId": "b7d4e2c1-9a3f-4c8e-8f21-6e5a0c9d3b22",
"status": "FISCALIZED",
"invoiceType": "NORMAL",
"transactionType": "SALE",
"clientReference": "ORDER-4127",
"idempotencyKey": "order-4127-sale-1",
"pfrInvoiceNumber": "JWX4K9PL-JWX4K9PL-1042",
"pfrSdcTime": "2026-11-02T10:14:03.1+01:00",
"totalAmount": 8990,
"verificationUrl": "https://sandbox.suf.purs.gov.rs/v/?vl=A0pXWDRLOVBMSldYNEs5UEwSBAAAEAQAAKCLPAAAAAAAAAABnAqJa1EAAAA...",
"failureCode": null
}
}POST /bokapos/webhooks HTTP/1.1
Host: shop.example.com
Content-Type: application/json; charset=utf-8
User-Agent: BokaPOS-Webhooks/1.0
webhook-id: 21c0738c-e29c-480f-92a9-fb88a99dc514
webhook-timestamp: 1793610844
webhook-signature: v1a,<base64>Provera potpisa
BokaPOS prati Standard Webhooks sa asimetričnom šemom v1a (Ed25519). Nema tajne po adresi koju biste morali da čuvate: BokaPOS potpisuje privatnim ključem platforme, a vi proveravate javnim ključem iz GET / (whpk_, pa base64 zapis ključa od 32 bajta). Isti ključ važi za sandbox i produkciju.
curl -X GET "https://api.bokapos.rs/v1/webhook-signing-keys" \
-H "Authorization: Bearer $BOKAPOS_TOKEN"{
"items": [
{
"id": "boka-2026-11",
"algorithm": "ed25519",
"publicKey": "whpk_tmKJ87mlOVyUIvJCGkDR/3Sndc35HZH6fQRJwdH/FO4="
}
]
}Ključ u primeru je izmišljen; pravi ključ pročitajte iz odgovora. Isti ključ važi za sandbox i produkciju.
Listu ključeva možete da keširate, ali je povremeno osvežite i verujte svakom ključu sa nje: tokom zamene ključa na listi su dva ključa, a svaka isporuka nosi po jedan potpis za svaki.
| Zaglavlje | Značenje |
|---|---|
webhook-id | id događaja, isti u svakom ponovnom pokušaju; po njemu odbacujete duplikate |
webhook-timestamp | Unix sekunde ovog pokušaja |
webhook-signature | v1a,<base64 potpis>, po jedan unos za svaki aktivni ključ, razdvojeni razmakom |
Sastavite potpisani tekst
{webhook-id}.{webhook-timestamp}.{telo}u UTF-8, gde je telo tačno onako kako je stiglo, bajt po bajt.Proverite potpis
Zahtev je ispravan ako se bar jedan
v1aunos izwebhook-signatureproveri javnim ključem.Proverite vreme
Odbacite zahtev čiji je
webhook-timestampviše od pet minuta udaljen od sata vašeg servera.Tek onda parsirajte JSON
Zahtev koji ne prođe provere odbijte i ne obrađujte.
U oba primera whpk je publicKey sa liste ključeva, id, timestamp i signatureHeader su vrednosti tri zaglavlja, a rawBody je sirovo telo.
$publicKey = base64_decode(substr($whpk, 5));
$signed = $id . '.' . $timestamp . '.' . $rawBody;
$valid = false;
foreach (explode(' ', $signatureHeader) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1a' && sodium_crypto_sign_verify_detached(base64_decode($signature), $signed, $publicKey)) {
$valid = true;
}
}
$valid = $valid && abs(time() - (int) $timestamp) <= 300;import { createPublicKey, verify } from "node:crypto";
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: Buffer.from(whpk.slice(5), "base64").toString("base64url") }, format: "jwk" });
const signed = Buffer.from(`${id}.${timestamp}.${rawBody}`);
const valid = signatureHeader.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
return version === "v1a" && verify(null, signed, key, Buffer.from(signature, "base64"));
}) && Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;Isporuka
- Najmanje jednom. Isti događaj može da stigne više puta; odbacite duplikate po
webhook-id. - Bez garantovanog redosleda. Događaji mogu da stignu drugim redom nego što su nastali. Kada vam treba trenutno stanje, pročitajte ga preko API-ja.
- Uspeh je `2xx` u roku od 10 sekundi. Sve ostalo je neuspeo pokušaj: drugi status, istek vremena, odbijena veza ili TLS greška. Preusmerenja se ne prate i računaju se kao neuspeh. Telo vašeg odgovora se ne čita.
- Ponavljanje. Neuspeo pokušaj se ponavlja posle 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h i 24 h: devet pokušaja tokom oko tri dana. Posle poslednjeg isporuka ima status
failed. - Isključenje posle 72 sata. Adresa čije isporuke ne uspevaju 72 sata, bez ijednog uspeha, se isključuje (
disabledReason: failing) i njene isporuke na čekanju se otkazuju. Uključite je ponovo u portalu ili saPUTienabled: true; isporuka se nastavlja sa novim događajima. - Samo novi događaji. Adresa prima samo događaje nastale posle njenog dodavanja.
- Spor prijemnik usporava samo sebe. Adrese se opslužuju uporedo, a pokušaji jedne adrese idu redom, pa spora ili nedostupna adresa nikad ne zadržava isporuke drugih.
Probni događaj i ponovno slanje
POST / šalje jedan potpisan webhook-endpoint.test samo toj adresi; tako proveravate prijemnik pre prvog pravog događaja. Adresa mora biti uključena.
curl -X POST "https://api.bokapos.rs/v1/webhook-endpoints/287978af-8d23-4e51-8c51-59d60c5fb9c3/test-events" \
-H "Authorization: Bearer $BOKAPOS_TOKEN"{
"eventId": "60e081b9-14f3-46e9-a26b-531436ddc947"
}Adresa za trenutak dobija potpisan zahtev sa type: webhook-endpoint.test i data: { webhookEndpointId }; ishod vidite u istoriji isporuka.
GET / vraća poslednjih 100 isporuka, najnovije prve. POST / pravi još jedan pokušaj odmah, van rasporeda, za uspelu, neuspelu ili otkazanu isporuku uključene adrese; neuspelo ponovno slanje ne pokreće novu seriju pokušaja.
curl -X GET "https://api.bokapos.rs/v1/webhook-endpoints/287978af-8d23-4e51-8c51-59d60c5fb9c3/deliveries" \
-H "Authorization: Bearer $BOKAPOS_TOKEN"{
"items": [
{
"id": "4b6f2c1e-8a3d-4f5b-9c7e-1d2a3b4c5d6e",
"eventId": "5c7a3d2f-9b4e-4a6c-8d1f-2e3b4c5d6e7f",
"eventType": "receipt-delivery.failed",
"status": "pending",
"attempts": 2,
"nextAttemptAt": "2026-11-02T09:41:20.000Z",
"lastAttemptAt": "2026-11-02T09:36:20.000Z",
"lastStatusCode": 502,
"lastFailureCode": "HTTP_STATUS",
"createdAt": "2026-11-02T09:35:19.800Z",
"updatedAt": "2026-11-02T09:36:20.000Z"
},
{
"id": "9fbe86a6-4d6a-4529-b162-ccacc98771a5",
"eventId": "21c0738c-e29c-480f-92a9-fb88a99dc514",
"eventType": "fiscal-document.fiscalized",
"status": "succeeded",
"attempts": 1,
"nextAttemptAt": null,
"lastAttemptAt": "2026-11-02T09:14:04.120Z",
"lastStatusCode": 200,
"lastFailureCode": null,
"createdAt": "2026-11-02T09:14:03.640Z",
"updatedAt": "2026-11-02T09:14:04.120Z"
}
]
}Prva isporuka je dva puta dobila 502 i čeka sledeći pokušaj; druga je uspela iz prvog pokušaja.
| lastFailureCode | Značenje |
|---|---|
HTTP_ | prijemnik je odgovorio statusom van 2xx; status je u lastStatusCode |
REDIRECT_ | prijemnik je odgovorio preusmerenjem (3xx), koje se ne prati |
TIMEOUT | nema odgovora u roku od 10 sekundi |
DNS_ | ime hosta nije razrešeno |
TLS_ | TLS veza ili provera sertifikata prijemnika nije uspela |
TARGET_ | host se razrešio na adresu koja nije javna, pa veza nije ni otvorena |
INVALID_ | sačuvana adresa više ne prolazi pravila za adrese |
CONNECTION_ | veza nije uspostavljena ili je prekinuta iz drugog razloga |
Upravljanje adresama
Adrese dodaju vlasnik i administratori organizacije u portalu cloud.bokapos.rs, u grupi Integracije, na stranici Webhook obaveštenja, ili vaš sistem preko API-ja sa scope-om webhooks:write, pa dodatak ili integracija može sama da registruje svoju adresu. Svaki API kredencijal nosi webhooks:read i webhooks:write.
curl -X POST "https://api.bokapos.rs/v1/webhook-endpoints" \
-H "Authorization: Bearer $BOKAPOS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://shop.example.com/bokapos/webhooks",
"description": "Web shop: status porudžbina",
"eventTypes": [
"fiscal-document.fiscalized",
"fiscal-document.outcome-unknown",
"receipt-delivery.failed"
]
}'{
"id": "287978af-8d23-4e51-8c51-59d60c5fb9c3",
"environment": "sandbox",
"url": "https://shop.example.com/bokapos/webhooks",
"description": "Web shop: status porudžbina",
"eventTypes": [
"fiscal-document.fiscalized",
"fiscal-document.outcome-unknown",
"receipt-delivery.failed"
],
"enabled": true,
"status": "enabled",
"disabledReason": null,
"failingSince": null,
"lastSuccessAt": null,
"lastFailureAt": null,
"createdBy": {
"kind": "api-credential",
"name": null
},
"createdAt": "2026-11-02T09:00:00.000Z",
"updatedAt": "2026-11-02T09:00:00.000Z"
}Kredencijal ne šalje environment: adresa uvek pripada njegovom okruženju. createdBy.name je null jer ime autora vide samo ljudi u portalu.
- Jedno okruženje po adresi. Adresa pripada sandboxu ili produkciji i prima samo događaje tog okruženja. Kredencijal uvek registruje u svom okruženju i polje
environmentmože da izostavi; ako ga pošalje, mora biti njegovo. U portalu okruženje birate sami. - Pravila za adresu.
urldo 2048 znakova, apsolutnihttps, bez korisničkih podataka i fragmenta, nikad loopback, privatna, link-local, carrier-grade NAT, dokumentaciona, multicast ili druga rezervisana adresa.descriptiondo 200 znakova. Bar jedan tip događaja. - Najviše deset adresa po organizaciji i okruženju.
- Jedan `url` jednom. Isti
urlu istom okruženju postoji samo jednom. Ponovna registracija vraća409 WEBHOOK_saENDPOINT_ URL_ EXISTS webhookEndpointIdpostojeće adrese, pa sistem koji se ponovo instalira nastavlja sa njom. - Izmena.
PUT /zamenjujev1/ webhook-endpoints/ {id} url,description,eventTypesienabledodjednom. Okruženje se nikad ne menja. - Uklanjanje.
DELETE /uklanja adresu i otkazuje njene isporuke na čekanju.v1/ webhook-endpoints/ {id} - Ko je dodao adresu.
createdBy.kindjeperson(vlasnik ili administrator u portalu),api-credentialiliai-connection. Ime ucreatedBy.namevide samo ljudi u portalu; mašinski kredencijal dobija samo vrstu. - Ko sme. Čitanje traži
webhooks:read, izmenewebhooks:write; u portalu samo vlasnik ili administrator sa aktivnim članstvom. BokaPOS podrška ne upravlja vašim adresama.
Neispravan zahtev (adresa, tipovi događaja, opis, okruženje) vraća 422 sa greškom po polju (application/).
Izmena koja naiđe na noviju verziju
Posle svakog pokušaja isporuke rezultat se upisuje na adresu, pa vaša izmena može da naiđe na noviju verziju zapisa. Tada PUT i DELETE vraćaju 409 WEBHOOK_, a ponovno slanje 409 WEBHOOK_. Odbijeni zahtev ništa ne menja: pročitajte zapis ponovo i ponovite izmenu.
Tok događaja
GET / vraća iste događaje koje webhook obaveštenja šalju, najstarije prve, za okruženje vašeg kredencijala. Koristite ga kao rezervu kada prijemnik nije bio dostupan, ili umesto webhook obaveštenja kada vaš sistem ne prima dolazne zahteve. Čita se sa webhooks:read.
| Parametar | Značenje |
|---|---|
after | kursor iz prethodnog odgovora (nextCursor); bez njega tok počinje od najstarijeg događaja |
types | tipovi razdvojeni zarezom, na primer fiscal-document.fiscalized,receipt-delivery.failed |
limit | 1 do 200, podrazumevano 100 |
curl -X GET "https://api.bokapos.rs/v1/events?after=1041&types=fiscal-document.fiscalized%2Cfiscal-document.outcome-unknown&limit=100" \
-H "Authorization: Bearer $BOKAPOS_TOKEN"{
"items": [
{
"id": "21c0738c-e29c-480f-92a9-fb88a99dc514",
"type": "fiscal-document.fiscalized",
"timestamp": "2026-11-02T09:14:03.512+00:00",
"environment": "sandbox",
"data": {
"fiscalDocumentId": "9f8e7d6c-5b4a-4321-8765-0fedcba98761",
"taxpayerId": "3f9c2a8e-6b1d-4e5a-9c47-1d2b8e6f0a11",
"businessPremiseId": "b7d4e2c1-9a3f-4c8e-8f21-6e5a0c9d3b22",
"status": "FISCALIZED",
"invoiceType": "NORMAL",
"transactionType": "SALE",
"clientReference": "ORDER-4127",
"idempotencyKey": "order-4127-sale-1",
"pfrInvoiceNumber": "JWX4K9PL-JWX4K9PL-1042",
"pfrSdcTime": "2026-11-02T10:14:03.1+01:00",
"totalAmount": 8990,
"verificationUrl": "https://sandbox.suf.purs.gov.rs/v/?vl=A0pXWDRLOVBMSldYNEs5UEwSBAAAEAQAAKCLPAAAAAAAAAABnAqJa1EAAAA...",
"failureCode": null
}
}
],
"nextCursor": "1042",
"hasMore": false
}Sačuvajte nextCursor i pošaljite ga kao after sledeći put. Dok je hasMore: true, odmah tražite sledeću stranu.
- Sačuvajte
nextCursori pošaljite ga kaoaftersledeći put. Kada nema novih događaja, vraća se isti kursor;nulldobijate samo dok još nema nijednog događaja. - Dok je
hasMore: true, odmah tražite sledeću stranu. - Kursor je pouzdan: BokaPOS događajima dodeljuje redosled tek posle potvrde promene, pa se događaj nikad ne pojavi iza kursora koji ste već prošli.
- Događaj se pojavljuje u toku trenutak posle promene, a tok pokriva poslednjih 30 dana. Probni događaji se nikad ne pojavljuju.
- Jedan događaj čitate sa
GET /.v1/ events/ {eventId}
Čuvanje
Webhook podaci su dnevnik obaveštenja, van desetogodišnjeg čuvanja fiskalnih podataka. Događaji, završene isporuke i uklonjene adrese brišu se automatski posle 30 dana. Isporuka koja se još ponavlja i njen događaj ostaju dok se ne završe. Fiskalni dokumenti i dnevnik aktivnosti se time ne diraju.
Bezbednost
- Samo javne HTTPS adrese. Adresa se proverava pri čuvanju i ponovo pri svakom povezivanju: ime hosta se razrešava i veza ide samo na javnu adresu, pa ni promena DNS zapisa ne može da usmeri isporuku u unutrašnju mrežu.
- Bez proksija i preusmerenja, rok od 10 sekundi, telo odgovora se odbacuje. Zahtev nosi
User-Agent: BokaPOS-Webhooks/.1.0 - Privatni ključ za potpis čuva BokaPOS u trezoru ključeva; vi dobijate samo javni ključ, pa sa vaše strane nema deljene tajne koja bi mogla da procuri.
- Proverite potpis i vreme pre svake obrade i odbacite sve što ne prolazi, čak i kada zahtev stiže sa poznate adrese.
AI veze
- AI veza (MCP konektor) upravlja webhook obaveštenjima samo uz šestu dozvolu, Webhook obaveštenja (
webhooks:readiwebhooks:write). Podrazumevano je isključena i u sandboxu i u produkciji; uključuje je vlasnik ili administrator, pri odobrenju veze ili kasnije u portalu, a u produkciji uz istu potvrdu kao ostale dozvole za upis. - Adresa koju AI aplikacija doda prima događaje vaše organizacije. Zato alati za upis nalažu AI aplikaciji da doda ili promeni adresu samo kada ste je vi naveli u razgovoru, i da je prvo potvrdi sa vama.
- Opoziv isključuje adrese. Kada se opozove API kredencijal, ukloni AI veza ili joj se oduzme dozvola Webhook obaveštenja, adrese koje je taj kredencijal dodao isključuju se u istom koraku (
disabledReason: revoked), a njihove isporuke na čekanju se otkazuju. Portal pokazuje ko je dodao svaku adresu (createdBy), a vlasnik ili administrator može ponovo da je uključi.
Greške
| HTTP | Kod | Značenje | Šta uraditi | Ponavljanje |
|---|---|---|---|---|
| 404 | WEBHOOK_ | Webhook adresa sa tim identifikatorom ne postoji u okruženju vašeg kredencijala ili je uklonjena. | Proverite identifikator i okruženje; GET / lista adrese koje vidite. | ispravi zahtev |
| 404 | WEBHOOK_ | Isporuka sa tim identifikatorom ne pripada ovoj adresi. | Uzmite identifikator iz GET / iste adrese. | ispravi zahtev |
| 409 | WEBHOOK_ | Druga adresa u istom okruženju već koristi ovaj url. Pri dodavanju odgovor nosi webhookEndpointId postojeće adrese. | Nastavite sa postojećom adresom (pročitajte je ili izmenite) umesto da dodajete novu. | nikad |
| 409 | WEBHOOK_ | Organizacija već ima deset webhook adresa u ovom okruženju. | Uklonite adresu koja vam ne treba ili dodajte tipove događaja postojećoj. | nikad |
| 409 | WEBHOOK_ | Adresa se promenila između vašeg čitanja i izmene ili uklanjanja (posle svakog pokušaja isporuke upisuje se rezultat). Ništa nije promenjeno. | Pročitajte adresu ponovo i ponovite isti zahtev. | isti ključ |
| 409 | WEBHOOK_ | Adresa je isključena (ručno, posle 72 sata neuspešnih isporuka ili opozivom kredencijala koji ju je dodao), pa probni događaj i ponovno slanje nisu mogući. | Uključite adresu (PUT sa enabled: true ili u portalu), pa ponovite. | kasnije |
| 409 | WEBHOOK_ | Isporuka još čeka u redu ili se upravo šalje, pa se ne može poslati ponovo. | Sačekajte da se završi (succeeded, failed ili cancelled u istoriji isporuka). | kasnije |
| 409 | WEBHOOK_ | Isporuka se promenila između vašeg čitanja i zahteva za ponovno slanje. Ništa nije promenjeno. | Pročitajte istoriju isporuka ponovo i, ako je isporuka završena, ponovite zahtev. | isti ključ |
| 404 | EVENT_ | Događaj ne postoji u vašem okruženju, stariji je od 30 dana ili je probni (webhook-endpoint.test), koji se ne čita iz toka događaja. | Proverite identifikator i okruženje. Događaji stariji od 30 dana se brišu; trenutno stanje pročitajte preko API-ja. | nikad |
Sve operacije sa poljima i primerima su u referenci, grupa Webhook obaveštenja.