E-Faktura / SEF
E-Faktura / SEF (Developer Reference)
Section titled “E-Faktura / SEF (Developer Reference)”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.
Overview
Section titled “Overview”- 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 (fromsef_settings) and a circuit breaker, then runs the send (build UBL, call SEF, updatesef_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 insef_webhook_log, and for each Purchase change fetches the invoice from SEF (through the same rate limiter) and upsertspos_invoiceandsef_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.
Queue and worker
Section titled “Queue and worker”- pg-boss uses the same PostgreSQL as the app (
SERVER_POSTGRES_URL). The server starts pg-boss at startup, creates theexternal-sefqueue, and passes anenqueueSeffunction 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 forexternal-sef. For each job it loads the org’ssef_settings, gets rate limit options (getSefRateLimitOptionsfrom@repo/db), then runsscheduleWithRateLimit('sef', options, () => sendToSef(db, payload))and a circuit breaker around the actual SEF call. - Scripts: From repo root,
pnpm workerorpnpm worker:devruns the worker. In production, run both the server and the worker (sameSERVER_POSTGRES_URL).
Rate limiting
Section titled “Rate limiting”- 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 fromorganization_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.
Endpoints
Section titled “Endpoints”Webhook (incoming from SEF)
Section titled “Webhook (incoming from SEF)”- 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 inorganization_integrationbysef_settings->>'subscriptionSecret'and processes the payload for that org. If the secret is unknown, respond with401. - Body:
{ "changes": [ { "invoiceId", "status", "date", "direction", "counterpartyId", "type" } ] }. Only entries withdirection === "Purchase"are processed (fetch invoice from SEF and upsert intopos_invoice). - Implementation:
handleSefWebhook(db, request)in@repo/api/server. Registered in the server app before the main API handler.
Cron: Subscribe for all organizations
Section titled “Cron: Subscribe for all organizations”- 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>
- Header:
- Behaviour: Loads all rows from
organization_integrationwheresef_settings.baseUrlandsef_settings.apiKeyare set. For each, calls SEFPOST /api/publicApi/subscribewith webhook URL{PUBLIC_SERVER_URL}{PUBLIC_SERVER_API_PATH}/sef/webhook, then stores the returned secret and webhook URL inorganization_integration.sef_settings(subscriptionSecret,webhookUrl). - Response:
200with JSON{ "subscribed": N, "total": N, "results": [...] }.401if the secret is missing or wrong. - When to call: Once per day (e.g. cron at 02:00). If
SEF_CRON_SECRETis not set, the endpoint always returns401.
Example (run daily from cron):
curl -X POST "https://api.yourapp.com/api/internal/sef-subscribe" \ -H "X-Cron-Secret: $SEF_CRON_SECRET"User-facing subscribe (optional)
Section titled “User-facing subscribe (optional)”- 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.
Manual sync (sales and purchase)
Section titled “Manual sync (sales and purchase)”Users can trigger a manual SEF sync from Settings → SEF → Sync (date range and optional status). The server:
- Calls SEF
POST /api/publicApi/sales-invoice/idsandPOST /api/publicApi/purchase-invoice/idswith query paramsdateFrom,dateTo, and optionalstatus(through the rate limiter). - For each returned ID, calls
GET /api/publicApi/sales-invoice?invoiceId=orGET /api/publicApi/purchase-invoice?invoiceId=, thenGET .../xmlfor UBL (also rate-limited). - Uses generic
syncSefInvoice(db, orgId, sefInvoiceId, direction)to upsert: if asef_invoicerow exists (by org + sef_invoice_id + direction), it is updated; otherwise a newpos_invoiceandsef_invoiceare created.
- Procedure:
POST /settings/efaktura/sync(oRPCsettings.syncSef). Input:{ dateFrom: string, dateTo: string, status?: string }. Output:{ salesSynced, purchaseSynced, errors: string[] }. - Implementation:
runSyncSefManualinpackages/api/src/features/pos/efaktura-workflow/sync-sef-manual.ts; generic sync insync-sef-invoice.ts. UBL parsing inparse-ubl-purchase-invoice.ts(parseUblInvoice).
Database (SEF-related)
Section titled “Database (SEF-related)”- 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_settings (jsonb): All SEF config in one object. Keys (no
- 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 formersef_request_logandfiscalization_log(SDC usesservice=sdc-eorsdc-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).
Environment
Section titled “Environment”| Variable | Purpose |
|---|---|
PUBLIC_SERVER_URL | Public base URL of the server. Must be reachable by SEF for the webhook (e.g. https://api.yourapp.com). |
PUBLIC_SERVER_API_PATH | API path prefix (default /api). Webhook URL = PUBLIC_SERVER_URL + PUBLIC_SERVER_API_PATH + /sef/webhook. |
SEF_CRON_SECRET | Secret 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_URL | Used by both server and worker (pg-boss and app DB). Required for queue and worker. |
EFAKTURA_BASE_URL, EFAKTURA_API_KEY | Optional 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).
Sending a sales invoice (server flow)
Section titled “Sending a sales invoice (server flow)”- User triggers “Send to eFaktura” for a finalized sales invoice.
- API loads organization (for legal/bank data, including bank account) and
organization_integration.sef_settings(forbaseUrl/apiKeyor env fallback). - 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.”
- API enqueues a job to
external-sefwith payload{ organizationId, invoiceId, seller, sefConfig }and returns{ accepted: true, jobId }. - Worker dequeues the job, loads
sef_settingsfor rate limit options, runsscheduleWithRateLimit('sef', getSefRateLimitOptions(sefSettings), () => sendToSef(db, payload)).sendToSefbuilds UBL, calls SEF, logs toexternal_request_log, and upsertssef_invoice.
Receiving purchase invoices (server flow)
Section titled “Receiving purchase invoices (server flow)”- Cron (or manual) calls
POST .../internal/sef-subscribedaily; server subscribes each org with SEF config and storessubscriptionSecretandwebhookUrlinorganization_integration.sef_settings. - SEF sends
POST .../sef/webhookwithX-SEF-Signature: <secret>and body{ "changes": [...] }. - Server resolves org by
sef_settings->>'subscriptionSecret', optionally logs body insef_webhook_log, then for each Purchase change calls syncSefInvoice (rate-limited) to fetch the invoice from SEF and upsertpos_invoiceandsef_invoice.
Packages
Section titled “Packages”| Package | Role |
|---|---|
@repo/queue | pg-boss: queue names (external-sef, etc.), getBoss, createQueue, enqueue. |
@repo/rate-limit | Generic Bottleneck-based rate limiter: scheduleWithRateLimit(service, options, fn). No SEF-specific types. |
@repo/db | Schema and getSefRateLimitOptions(sef_settings) for SEF rate limit options. |
packages/api | Send-to-SEF handler (enqueue), fetch/sync/subscribe/accept (rate-limited), external_request_log. |
apps/worker | Consumes external-sef jobs; rate limit + circuit breaker; calls sendToSef from @repo/api/workflow. |
Summary for developers
Section titled “Summary for developers”| Concern | Where |
|---|---|
| Send to SEF | API 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 SEF | PUBLIC_SERVER_URL + PUBLIC_SERVER_API_PATH + /sef/webhook |
| Subscribe all orgs | Call POST .../internal/sef-subscribe daily with SEF_CRON_SECRET |
| Org resolution on webhook | organization_integration.sef_settings->>'subscriptionSecret' |
| SEF config per org | organization_integration.sef_settings (baseUrl, apiKey, rateLimit*, retry*, etc.) |
| Request/response log | external_request_log (service, idempotency_key, payloads) |
| Running in dev | pnpm 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).