Webhooks
BokaPOS tells you when something changes after your call: a receipt with an unknown outcome was issued after all, a receipt e-mail arrived or failed, a certificate is about to expire, the licence changed. Notifications arrive as signed HTTPS requests at your address, and you can also fetch the same events yourself from the feed.
What they are for
Fiscalization is synchronous: you get the receipt, or a 503, in the same request. A few things still change later, and for them you no longer need to poll the API or open the portal:
- an
OUTCOME_operation that BokaPOS later resolves toUNKNOWN FISCALIZED; - the result of a receipt e-mail (
DELIVEREDorFAILED); - a security element that is activated, suspended, retired or whose certificate is about to expire;
- a licence change, which decides whether production receipts are accepted;
- receipts issued from another channel (the portal's cloud POS, another integration) that your ERP or accounting needs to see.
Events carry only safe identifiers and summary facts. They never carry the buyer identification or details, line items, the raw V-PFR response, the receipt text, recipient e-mail addresses or secrets. Webhooks are free and included in the base licence, in the sandbox and in production.
Event types
| Type | When | Fields in data |
|---|---|---|
fiscal-document.fiscalized | Any operation reaches FISCALIZED: a sale, refund, copy, advance, proforma or training document, from the API, the portal, a workflow or the later resolution of an unknown outcome | fiscalDocumentId, taxpayerId, businessPremiseId, status, invoiceType, transactionType, clientReference, idempotencyKey, pfrInvoiceNumber, pfrSdcTime, totalAmount, verificationUrl |
fiscal-document.outcome-unknown | An operation first becomes OUTCOME_ (once per operation, never again) | the same identifiers, status, failureCode; the PFR fields are null |
receipt-delivery.delivered | The mail server accepted the receipt e-mail | receiptDeliveryId, fiscalDocumentId, taxpayerId, businessPremiseId, channel, status, attempts, deliveredAt |
receipt-delivery.failed | The receipt e-mail failed for good (no attempt left) | the same, plus failureCode |
security-element.activated | An element becomes active | securityElementId, taxpayerId, businessPremiseId, jid, status, certificateNotAfter |
security-element.suspended | An element becomes suspended | the same |
security-element.retired | An element becomes retired | the same |
security-element.expiring | An active element's certificate enters the warning window (30 days before expiry), then the critical window (7 days), once each | the same, plus expiryStatus (warning or critical) and daysRemaining |
license.updated | BokaPOS issues or changes the organization's licence | status, startsOn, endsOn, advanceModuleEnabled, emailModuleEnabled |
webhook-endpoint.test | Only on your request, to one endpoint; never in the feed | webhookEndpointId |
Licence events belong to the production environment, because the licence only gates production. The 30-day and 7-day windows are the same as certificateExpiryStatus in GET / and the expiry badges in the portal. You can subscribe to every type except webhook-endpoint.test.
Event shape
Every webhook body and every feed item have the same shape, so the same code handles a pushed event and a pulled one. type decides the shape of data, environment names the environment (sandbox or production), and timestamp is when the change happened, not when it was delivered.
{
"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>Verifying the signature
BokaPOS follows Standard Webhooks with its asymmetric v1a scheme (Ed25519). There is no per-endpoint secret for you to keep: BokaPOS signs with the platform's private key, and you verify with the public key from GET / (whpk_ followed by the base64 of the 32-byte key). The same key serves sandbox and production.
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="
}
]
}The key in this example is made up; read the real one from the response. The same key serves sandbox and production.
You may cache the key list, but refresh it now and then and trust every key on it: during a rotation the list holds two keys and every delivery carries one signature per key.
| Header | Meaning |
|---|---|
webhook-id | the event id, identical on every retry; drop duplicates by it |
webhook-timestamp | Unix seconds of this attempt |
webhook-signature | v1a,<base64 signature>, one entry per active key, separated by spaces |
Build the signed content
{webhook-id}.{webhook-timestamp}.{body}as UTF-8, where the body is exactly as it arrived, byte for byte.Check the signature
The request is valid when at least one
v1aentry ofwebhook-signatureverifies with the public key.Check the time
Reject a request whose
webhook-timestampis more than five minutes away from your server's clock.Only then parse the JSON
Refuse a request that fails the checks and do not process it.
In both examples whpk is the publicKey from the key list, id, timestamp and signatureHeader are the values of the three headers, and rawBody is the raw body.
$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;Delivery
- At least once. The same event can arrive more than once; drop duplicates by
webhook-id. - No guaranteed order. Events can arrive in a different order than they happened. When you need the current state, read it through the API.
- Success is a `2xx` within 10 seconds. Anything else is a failed attempt: another status, a timeout, a refused connection or a TLS failure. Redirects are not followed and count as failures. Your response body is not read.
- Retries. A failed attempt is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h: nine attempts over about three days. After the last one the delivery is
failed. - Disabled after 72 hours. An endpoint whose deliveries have failed for 72 hours without a single success is disabled (
disabledReason: failing) and its pending deliveries are cancelled. Switch it back on in the portal or withPUTandenabled: true; delivery resumes with new events. - New events only. An endpoint receives only events that happened after it was added.
- A slow receiver only slows itself. Endpoints are served side by side and each endpoint's attempts go in order, so a slow or unreachable address never holds up anyone else's deliveries.
Test event and resend
POST / sends one signed webhook-endpoint.test to that endpoint only; use it to check your receiver before the first real event. The endpoint must be enabled.
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"
}The endpoint shortly receives a signed request with type: webhook-endpoint.test and data: { webhookEndpointId }; the outcome shows in the delivery history.
GET / returns the latest 100 deliveries, newest first. POST / makes one more attempt right away, outside the schedule, for a succeeded, failed or cancelled delivery of an enabled endpoint; a failed resend does not start a new retry series.
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"
}
]
}The first delivery got 502 twice and waits for its next attempt; the second succeeded on the first attempt.
| lastFailureCode | Meaning |
|---|---|
HTTP_ | the receiver answered with a status outside 2xx; the status is in lastStatusCode |
REDIRECT_ | the receiver answered with a redirect (3xx), which is not followed |
TIMEOUT | no answer within 10 seconds |
DNS_ | the host name did not resolve |
TLS_ | the TLS connection or the receiver's certificate check failed |
TARGET_ | the host resolved to an address that is not public, so no connection was opened |
INVALID_ | the stored address no longer passes the address rules |
CONNECTION_ | the connection was not established or broke for another reason |
Managing endpoints
Endpoints are added by the organization's Owner and Administrators in the portal at cloud.bokapos.rs, in the Integrations group on the Webhooks page, or by your system through the API with the webhooks:write scope, so a plugin or integration can register its own address. Every API credential carries webhooks:read and 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"
}The credential sends no environment: the endpoint always belongs to its own environment. createdBy.name is null because only people in the portal see the author's name.
- One environment per endpoint. An endpoint belongs to the sandbox or to production and receives only that environment's events. A credential always registers in its own environment and may omit
environment; if it sends it, it must be its own. In the portal you choose the environment. - Address rules.
urlat most 2048 characters, absolutehttps, without user information or a fragment, never a loopback, private, link-local, carrier-grade NAT, documentation, multicast or otherwise reserved address.descriptionat most 200 characters. At least one event type. - At most ten endpoints per organization and environment.
- One `url` once. The same
urlexists only once per environment. Registering it again answers409 WEBHOOK_with the existing endpoint'sENDPOINT_ URL_ EXISTS webhookEndpointId, so a system that is installed again continues with it. - Updating.
PUT /replacesv1/ webhook-endpoints/ {id} url,description,eventTypesandenabledat once. The environment never changes. - Removing.
DELETE /removes the endpoint and cancels its pending deliveries.v1/ webhook-endpoints/ {id} - Who added an endpoint.
createdBy.kindisperson(an Owner or Administrator in the portal),api-credentialorai-connection. Only people in the portal see the name increatedBy.name; a machine credential gets the kind only. - Who may. Reading needs
webhooks:read, changes needwebhooks:write; in the portal only an Owner or Administrator with an active membership. BokaPOS support does not manage your endpoints.
An invalid request (address, event types, description, environment) answers 422 with a per-field error (application/).
A change that meets a newer version
Every delivery attempt records its result on the endpoint, so your change can meet a newer version of the record. PUT and DELETE then answer 409 WEBHOOK_, and a resend 409 WEBHOOK_. The refused request changes nothing: read the record again and repeat the change.
Event feed
GET / returns the same events that webhooks push, oldest first, for your credential's environment. Use it as a fallback when your receiver was unavailable, or instead of webhooks when your system cannot accept incoming requests. It needs webhooks:read.
| Parameter | Meaning |
|---|---|
after | the cursor from the previous answer (nextCursor); without it the feed starts at the oldest event |
types | comma-separated types, for example fiscal-document.fiscalized,receipt-delivery.failed |
limit | 1 to 200, default 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
}Keep nextCursor and send it as after next time. While hasMore is true, ask for the next page right away.
- Keep
nextCursorand send it asafternext time. When nothing new happened, the same cursor comes back; you getnullonly while there is no event at all. - While
hasMoreistrue, ask for the next page right away. - The cursor is reliable: BokaPOS orders events only after the change has committed, so an event never appears behind a cursor you have already passed.
- An event appears in the feed a moment after the change, and the feed covers the last 30 days. Test events never appear.
- Read one event with
GET /.v1/ events/ {eventId}
Retention
Webhook data is a notification log, outside the ten-year fiscal retention. Events, finished deliveries and removed endpoints are deleted automatically after 30 days. A delivery still being retried, and its event, stay until it finishes. Fiscal documents and the activity log are never touched.
Security
- Public HTTPS addresses only. The address is checked when it is saved and again at every connection: the host name is resolved and the connection goes only to a public address, so not even a DNS change can steer a delivery into an internal network.
- No proxy and no redirects, a 10-second timeout, the response body is discarded. Requests carry
User-Agent: BokaPOS-Webhooks/.1.0 - BokaPOS keeps the private signing key in a key vault; you get only the public key, so there is no shared secret on your side that could leak.
- Verify the signature and the time before any processing and drop whatever fails, even when the request comes from a familiar address.
AI connections
- An AI connection (the MCP connector) manages webhooks only with the sixth permission, Webhooks (
webhooks:readandwebhooks:write). It is off by default in both the sandbox and production; the Owner or an Administrator turns it on, when approving the connection or later in the portal, and in production with the same confirmation as the other write permissions. - An address the AI app adds receives your organization's events. That is why the write tools tell the AI app to add or change an address only when you gave it in the conversation, and to confirm it with you first.
- Revocation disables endpoints. When an API credential is revoked, an AI connection is removed or loses the Webhooks permission, the endpoints that credential added are disabled in the same step (
disabledReason: revoked) and their pending deliveries are cancelled. The portal shows who added each endpoint (createdBy), and the Owner or an Administrator can switch it back on.
Errors
| HTTP | Code | Meaning | What to do | Retry |
|---|---|---|---|---|
| 404 | WEBHOOK_ | No webhook endpoint with that identifier exists in your credential's environment, or it was removed. | Check the identifier and the environment; GET / lists the endpoints you can see. | fix the request |
| 404 | WEBHOOK_ | No delivery with that identifier belongs to this endpoint. | Take the identifier from GET / of the same endpoint. | fix the request |
| 409 | WEBHOOK_ | Another endpoint in the same environment already uses this url. When adding, the answer carries the existing endpoint's webhookEndpointId. | Continue with the existing endpoint (read or update it) instead of adding a new one. | never |
| 409 | WEBHOOK_ | The organization already has ten webhook endpoints in this environment. | Remove an endpoint you no longer need or add the event types to an existing one. | never |
| 409 | WEBHOOK_ | The endpoint changed between your read and your update or removal (every delivery attempt records its result). Nothing was changed. | Read the endpoint again and repeat the same request. | same key |
| 409 | WEBHOOK_ | The endpoint is disabled (by hand, after 72 hours of failed deliveries or by revoking the credential that added it), so a test event or a resend is not possible. | Enable the endpoint (PUT with enabled: true or in the portal), then repeat. | later |
| 409 | WEBHOOK_ | The delivery is still queued or being sent, so it cannot be resent. | Wait until it finishes (succeeded, failed or cancelled in the delivery history). | later |
| 409 | WEBHOOK_ | The delivery changed between your read and the resend request. Nothing was changed. | Read the delivery history again and, when the delivery has finished, repeat the request. | same key |
| 404 | EVENT_ | The event does not exist in your environment, is older than 30 days, or is a test event (webhook-endpoint.test), which the feed never serves. | Check the identifier and the environment. Events older than 30 days are deleted; read the current state through the API. | never |
Every operation with its fields and examples is in the reference, group Webhooks.