Skip to content

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.

Updated: 2 November 2026 · Contract version 1.0.0

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_UNKNOWN operation that BokaPOS later resolves to FISCALIZED;
  • the result of a receipt e-mail (DELIVERED or FAILED);
  • 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

TypeWhenFields in data
fiscal-document.fiscalizedAny 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 outcomefiscalDocumentId, taxpayerId, businessPremiseId, status, invoiceType, transactionType, clientReference, idempotencyKey, pfrInvoiceNumber, pfrSdcTime, totalAmount, verificationUrl
fiscal-document.outcome-unknownAn operation first becomes OUTCOME_UNKNOWN (once per operation, never again)the same identifiers, status, failureCode; the PFR fields are null
receipt-delivery.deliveredThe mail server accepted the receipt e-mailreceiptDeliveryId, fiscalDocumentId, taxpayerId, businessPremiseId, channel, status, attempts, deliveredAt
receipt-delivery.failedThe receipt e-mail failed for good (no attempt left)the same, plus failureCode
security-element.activatedAn element becomes activesecurityElementId, taxpayerId, businessPremiseId, jid, status, certificateNotAfter
security-element.suspendedAn element becomes suspendedthe same
security-element.retiredAn element becomes retiredthe same
security-element.expiringAn active element's certificate enters the warning window (30 days before expiry), then the critical window (7 days), once eachthe same, plus expiryStatus (warning or critical) and daysRemaining
license.updatedBokaPOS issues or changes the organization's licencestatus, startsOn, endsOn, advanceModuleEnabled, emailModuleEnabled
webhook-endpoint.testOnly on your request, to one endpoint; never in the feedwebhookEndpointId

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 /v1/security-elements 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.

Request body
{
  "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
  }
}
Request headers
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 /v1/webhook-signing-keys (whpk_ followed by the base64 of the 32-byte key). The same key serves sandbox and production.

GET/v1/webhook-signing-keys
curl -X GET "https://api.bokapos.rs/v1/webhook-signing-keys" \
  -H "Authorization: Bearer $BOKAPOS_TOKEN"
200OK
{
  "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.

HeaderMeaning
webhook-idthe event id, identical on every retry; drop duplicates by it
webhook-timestampUnix seconds of this attempt
webhook-signaturev1a,<base64 signature>, one entry per active key, separated by spaces
  1. Build the signed content

    {webhook-id}.{webhook-timestamp}.{body} as UTF-8, where the body is exactly as it arrived, byte for byte.

  2. Check the signature

    The request is valid when at least one v1a entry of webhook-signature verifies with the public key.

  3. Check the time

    Reject a request whose webhook-timestamp is more than five minutes away from your server's clock.

  4. 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.

PHP (sodium is built in since PHP 7.2; WordPress ships sodium_compat)
$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;
Node.js
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 with PUT and enabled: 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 /v1/webhook-endpoints/{id}/test-events 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.

POST/v1/webhook-endpoints/{webhookEndpointId}/test-events
curl -X POST "https://api.bokapos.rs/v1/webhook-endpoints/287978af-8d23-4e51-8c51-59d60c5fb9c3/test-events" \
  -H "Authorization: Bearer $BOKAPOS_TOKEN"
202Accepted
{
  "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 /v1/webhook-endpoints/{id}/deliveries returns the latest 100 deliveries, newest first. POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/resend 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.

GET/v1/webhook-endpoints/{webhookEndpointId}/deliveries
curl -X GET "https://api.bokapos.rs/v1/webhook-endpoints/287978af-8d23-4e51-8c51-59d60c5fb9c3/deliveries" \
  -H "Authorization: Bearer $BOKAPOS_TOKEN"
200OK
{
  "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.

lastFailureCodeMeaning
HTTP_STATUSthe receiver answered with a status outside 2xx; the status is in lastStatusCode
REDIRECT_NOT_FOLLOWEDthe receiver answered with a redirect (3xx), which is not followed
TIMEOUTno answer within 10 seconds
DNS_FAILEDthe host name did not resolve
TLS_FAILEDthe TLS connection or the receiver's certificate check failed
TARGET_NOT_PUBLICthe host resolved to an address that is not public, so no connection was opened
INVALID_TARGETthe stored address no longer passes the address rules
CONNECTION_FAILEDthe 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.

POST/v1/webhook-endpoints
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"
  ]
}'
201Created
{
  "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. url at most 2048 characters, absolute https, without user information or a fragment, never a loopback, private, link-local, carrier-grade NAT, documentation, multicast or otherwise reserved address. description at most 200 characters. At least one event type.
  • At most ten endpoints per organization and environment.
  • One `url` once. The same url exists only once per environment. Registering it again answers 409 WEBHOOK_ENDPOINT_URL_EXISTS with the existing endpoint's webhookEndpointId, so a system that is installed again continues with it.
  • Updating. PUT /v1/webhook-endpoints/{id} replaces url, description, eventTypes and enabled at once. The environment never changes.
  • Removing. DELETE /v1/webhook-endpoints/{id} removes the endpoint and cancels its pending deliveries.
  • Who added an endpoint. createdBy.kind is person (an Owner or Administrator in the portal), api-credential or ai-connection. Only people in the portal see the name in createdBy.name; a machine credential gets the kind only.
  • Who may. Reading needs webhooks:read, changes need webhooks: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/problem+json).

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_ENDPOINT_CHANGED, and a resend 409 WEBHOOK_DELIVERY_CHANGED. The refused request changes nothing: read the record again and repeat the change.

Event feed

GET /v1/events 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.

ParameterMeaning
afterthe cursor from the previous answer (nextCursor); without it the feed starts at the oldest event
typescomma-separated types, for example fiscal-document.fiscalized,receipt-delivery.failed
limit1 to 200, default 100
GET/v1/events
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"
200OK
{
  "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 nextCursor and send it as after next time. When nothing new happened, the same cursor comes back; you get null only while there is no event at all.
  • While hasMore is true, 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:read and webhooks: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

HTTPCodeMeaningWhat to doRetry
404WEBHOOK_ENDPOINT_NOT_FOUNDNo webhook endpoint with that identifier exists in your credential's environment, or it was removed.Check the identifier and the environment; GET /v1/webhook-endpoints lists the endpoints you can see.fix the request
404WEBHOOK_DELIVERY_NOT_FOUNDNo delivery with that identifier belongs to this endpoint.Take the identifier from GET /v1/webhook-endpoints/{id}/deliveries of the same endpoint.fix the request
409WEBHOOK_ENDPOINT_URL_EXISTSAnother 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
409WEBHOOK_ENDPOINT_LIMIT_REACHEDThe 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
409WEBHOOK_ENDPOINT_CHANGEDThe 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
409WEBHOOK_ENDPOINT_DISABLEDThe 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
409WEBHOOK_DELIVERY_IN_PROGRESSThe 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
409WEBHOOK_DELIVERY_CHANGEDThe 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
404EVENT_NOT_FOUNDThe 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.