Skip to content

E-Faktura / SEF

This page describes how AnyBiz integrates with SEF (Služba za elektronsko fakturisanje) for electronic invoicing: sending sales invoices (UBL) via a job queue and worker, receiving purchase invoices via webhook, and subscribing for notifications. It covers architecture, endpoints, database, rate limiting, and environment for developers and DevOps.

  • Sales (Send to SEF): Users trigger “Send to eFaktura” for a finalized sales invoice. The API validates, loads org/invoice/SEF config, then enqueues a job to pg-boss (external-sef). The worker process picks up the job, applies rate limiting (from sef_settings) and a circuit breaker, then runs the send (build UBL, call SEF, update sef_invoice). The API returns { accepted: true, jobId }; the actual send runs asynchronously.
  • Purchase: SEF sends notification payloads to our webhook. The server identifies the organization by secret (from sef_settings.subscriptionSecret), logs the payload in sef_webhook_log, and for each Purchase change fetches the invoice from SEF (through the same rate limiter) and upserts pos_invoice and sef_invoice.
  • Subscribe: SEF subscriptions are valid only for the next day. The server exposes an internal cron endpoint that subscribes all organizations with eFaktura config; you run this once per day (e.g. from cron). The returned secret and webhook URL are stored in organization_integration.sef_settings.

Naming in code and DB is SEF (e.g. sef_invoice, sef_settings, external-sef queue). The UI may still use “eFaktura” for user-facing labels.

  • pg-boss uses the same PostgreSQL as the app (SERVER_POSTGRES_URL). The server starts pg-boss at startup, creates the external-sef queue, and passes an enqueueSef function into the API so the send-to-SEF handler can enqueue jobs instead of calling SEF directly.
  • Worker (apps/worker): A separate process that connects to the same DB, starts pg-boss, and registers a handler for external-sef. For each job it loads the org’s sef_settings, gets rate limit options (getSefRateLimitOptions from @repo/db), then runs scheduleWithRateLimit('sef', options, () => sendToSef(db, payload)) and a circuit breaker around the actual SEF call.
  • Scripts: From repo root, pnpm worker or pnpm worker:dev runs the worker. In production, run both the server and the worker (same SERVER_POSTGRES_URL).
  • SEF allows 3 requests per second. All SEF API calls (from both the API and the worker) go through @repo/rate-limit (Bottleneck). Options come from organization_integration.sef_settings: rateLimitMinTimeMs, rateLimitMaxConcurrent. Defaults when null: 334 ms and 1 concurrent (≈3 req/s).
  • API: Fetch/sync/subscribe/accept-purchase use scheduleWithRateLimit('sef', getSefRateLimitOptions(sef_settings), fn) so in-process SEF calls are rate-limited.
  • Worker: Send-to-SEF jobs use the same scheduleWithRateLimit('sef', getSefRateLimitOptions(integration?.sefSettings), fn) so all SEF traffic respects the same limits. Other services (e.g. SDC) can use the same package with their own settings and service name.
  • URL: POST {PUBLIC_SERVER_URL}{PUBLIC_SERVER_API_PATH}/sef/webhook
    Example: POST https://api.yourapp.com/api/sef/webhook
  • Routing: SEF sends header X-SEF-Signature: <secret>. The server looks up the organization in organization_integration by sef_settings->>'subscriptionSecret' and processes the payload for that org. If the secret is unknown, respond with 401.
  • Body: { "changes": [ { "invoiceId", "status", "date", "direction", "counterpartyId", "type" } ] }. Only entries with direction === "Purchase" are processed (fetch invoice from SEF and upsert into pos_invoice).
  • Implementation: handleSefWebhook(db, request) in @repo/api/server. Registered in the server app before the main API handler.
  • URL: POST {PUBLIC_SERVER_URL}{PUBLIC_SERVER_API_PATH}/internal/sef-subscribe
    Example: POST https://api.yourapp.com/api/internal/sef-subscribe
  • Auth: One of:
    • Header: X-Cron-Secret: <SEF_CRON_SECRET>
    • Header: Authorization: Bearer <SEF_CRON_SECRET>
  • Behaviour: Loads all rows from organization_integration where sef_settings.baseUrl and sef_settings.apiKey are set. For each, calls SEF POST /api/publicApi/subscribe with webhook URL {PUBLIC_SERVER_URL}{PUBLIC_SERVER_API_PATH}/sef/webhook, then stores the returned secret and webhook URL in organization_integration.sef_settings (subscriptionSecret, webhookUrl).
  • Response: 200 with JSON { "subscribed": N, "total": N, "results": [...] }. 401 if the secret is missing or wrong.
  • When to call: Once per day (e.g. cron at 02:00). If SEF_CRON_SECRET is not set, the endpoint always returns 401.

Example (run daily from cron):

Terminal window
curl -X POST "https://api.yourapp.com/api/internal/sef-subscribe" \
-H "X-Cron-Secret: $SEF_CRON_SECRET"
  • Procedure: POST /api/pos/efaktura/subscribe-for-sef (oRPC). Input: { "webhookUrl": "https://api.yourapp.com/api/sef/webhook" }. Requires an authenticated session.
  • Use: Optional manual trigger; the main mechanism is the cron endpoint above.

Users can trigger a manual SEF sync from Settings → SEF → Sync (date range and optional status). The server:

  1. Calls SEF POST /api/publicApi/sales-invoice/ids and POST /api/publicApi/purchase-invoice/ids with query params dateFrom, dateTo, and optional status (through the rate limiter).
  2. For each returned ID, calls GET /api/publicApi/sales-invoice?invoiceId= or GET /api/publicApi/purchase-invoice?invoiceId=, then GET .../xml for UBL (also rate-limited).
  3. Uses generic syncSefInvoice(db, orgId, sefInvoiceId, direction) to upsert: if a sef_invoice row exists (by org + sef_invoice_id + direction), it is updated; otherwise a new pos_invoice and sef_invoice are created.
  • Procedure: POST /settings/efaktura/sync (oRPC settings.syncSef). Input: { dateFrom: string, dateTo: string, status?: string }. Output: { salesSynced, purchaseSynced, errors: string[] }.
  • Implementation: runSyncSefManual in packages/api/src/features/pos/efaktura-workflow/sync-sef-manual.ts; generic sync in sync-sef-invoice.ts. UBL parsing in parse-ubl-purchase-invoice.ts (parseUblInvoice).
  • organization_integration
    • sef_settings (jsonb): All SEF config in one object. Keys (no sef_ prefix): baseUrl, apiKey, subscriptionSecret, webhookUrl, rateLimitMinTimeMs, rateLimitMaxConcurrent, retryLimit, retryDelay, retryBackoff, cbThreshold, cbTimeoutMs. Null or missing fields use defaults (e.g. 334 ms / 1 concurrent for rate limit).
  • sef_invoice: One row per SEF-linked invoice (sales or purchase). Stores sef_invoice_id, direction, status, sef_status, sef_response (jsonb), and version-based sync columns. Used to decide create vs update on sync (by org + sef_invoice_id + direction).
  • external_request_log: Outgoing request/response log for external APIs (SEF, SDC). Columns: organization_id, service, idempotency_key (unique per service), request_payload, response_payload, status, error_code, error_message, created_at. Used for idempotency and audit. Replaces the former sef_request_log and fiscalization_log (SDC uses service = sdc-e or sdc-v).
  • sef_webhook_log: Incoming webhook payloads (audit/debug).
  • pos_invoice.sef_invoice_id: Links purchase invoices imported from SEF to the SEF invoice id.

RLS is enabled on sef_invoice, external_request_log, and sef_webhook_log; policies use organization_id = current_setting('app.organization_id', true).

VariablePurpose
PUBLIC_SERVER_URLPublic base URL of the server. Must be reachable by SEF for the webhook (e.g. https://api.yourapp.com).
PUBLIC_SERVER_API_PATHAPI path prefix (default /api). Webhook URL = PUBLIC_SERVER_URL + PUBLIC_SERVER_API_PATH + /sef/webhook.
SEF_CRON_SECRETSecret for the cron endpoint POST .../internal/sef-subscribe. Set in production and pass in cron requests (header X-Cron-Secret or Authorization: Bearer).
SERVER_POSTGRES_URLUsed by both server and worker (pg-boss and app DB). Required for queue and worker.
EFAKTURA_BASE_URL, EFAKTURA_API_KEYOptional fallback when an organization has no eFaktura settings in the DB.

Worker: Copy apps/worker/.env.example to apps/worker/.env and set SERVER_POSTGRES_URL (same as server).

  1. User triggers “Send to eFaktura” for a finalized sales invoice.
  2. API loads organization (for legal/bank data, including bank account) and organization_integration.sef_settings (for baseUrl / apiKey or env fallback).
  3. If organization has no bank account, return error: “Payee financial account (IBAN / tekući račun) is required for eFaktura. Set it in Settings → Organization.”
  4. API enqueues a job to external-sef with payload { organizationId, invoiceId, seller, sefConfig } and returns { accepted: true, jobId }.
  5. Worker dequeues the job, loads sef_settings for rate limit options, runs scheduleWithRateLimit('sef', getSefRateLimitOptions(sefSettings), () => sendToSef(db, payload)). sendToSef builds UBL, calls SEF, logs to external_request_log, and upserts sef_invoice.
  1. Cron (or manual) calls POST .../internal/sef-subscribe daily; server subscribes each org with SEF config and stores subscriptionSecret and webhookUrl in organization_integration.sef_settings.
  2. SEF sends POST .../sef/webhook with X-SEF-Signature: <secret> and body { "changes": [...] }.
  3. Server resolves org by sef_settings->>'subscriptionSecret', optionally logs body in sef_webhook_log, then for each Purchase change calls syncSefInvoice (rate-limited) to fetch the invoice from SEF and upsert pos_invoice and sef_invoice.
PackageRole
@repo/queuepg-boss: queue names (external-sef, etc.), getBoss, createQueue, enqueue.
@repo/rate-limitGeneric Bottleneck-based rate limiter: scheduleWithRateLimit(service, options, fn). No SEF-specific types.
@repo/dbSchema and getSefRateLimitOptions(sef_settings) for SEF rate limit options.
packages/apiSend-to-SEF handler (enqueue), fetch/sync/subscribe/accept (rate-limited), external_request_log.
apps/workerConsumes external-sef jobs; rate limit + circuit breaker; calls sendToSef from @repo/api/workflow.
ConcernWhere
Send to SEFAPI enqueues to external-sef; worker runs sendToSef with rate limit from sef_settings.
Rate limit (3 req/s)@repo/rate-limit + getSefRateLimitOptions(sef_settings); used in API and worker.
Webhook URL for SEFPUBLIC_SERVER_URL + PUBLIC_SERVER_API_PATH + /sef/webhook
Subscribe all orgsCall POST .../internal/sef-subscribe daily with SEF_CRON_SECRET
Org resolution on webhookorganization_integration.sef_settings->>'subscriptionSecret'
SEF config per orgorganization_integration.sef_settings (baseUrl, apiKey, rateLimit*, retry*, etc.)
Request/response logexternal_request_log (service, idempotency_key, payloads)
Running in devpnpm dev (server + web); pnpm worker or pnpm worker:dev for the worker.

For full SQL schema and migrations, see the repository (packages/db/drizzle/). For user-facing steps, see User Guide: E-Faktura (SEF).