eFakturuj / API Docs
eFakturuj Guides

Webhooks

eFakturuj fires webhooks at your HTTPS endpoint when invoice state changes: delivered through Peppol, rejected by the receiver, or confirmed or rejected by Finančná správa (the C5 tax-data filing). Use them to trigger follow-on work (update your ERP, mark a deal won, send the customer a copy) without polling our API.

What you can subscribe to

Read directly from WebhookEventType in the backend, the currently emitted event types are:

EventWhen it firesEnvelope
invoice.deliveredThe receiving Peppol Access Point ACKed the SBDH wrapper for your invoice.legacy
invoice.receivedAn inbound Peppol invoice was received for your company.legacy
invoice.rejectedThe receiver returned a Peppol MLS rejection (validation, addressing, or business-rule failure).legacy
invoice.fs_acknowledgedFinančná správa confirmed the C5 tax-data copy of your sent or received invoice (positive MLS from C5).efk.event.v1
invoice.fs_rejectedFinančná správa rejected the C5 tax-data copy of your sent or received invoice (MLS RE from C5); data.reasons carries FS's findings.efk.event.v1
test.pingSynthetic event fired by POST /connect/webhooks/{webhook_id}/test so you can develop your handler without sending a real invoice.legacy

Partner lifecycle events — for partners only, delivered to endpoints registered on the partner's OWN company (the same Connect → Webhooks UI):

EventWhen it fires
client.linkedA pre-registered client's FS designation arrived and the company was auto-linked to your partner account (mass enrolment).
client.activatedThe client's Peppol participant registration was verified on the network — the company is fully operational.
client.offboardingOffboarding started for a managed client: the company is frozen and the 90-day retention clock is running (offboarding).
client.purgedThe retention window ended and the client's business data was deleted; only the company tombstone remains.
export.completedA background export finished — the payload carries signed 7-day download URLs and SHA-256 checksums per part (massive export).

If you subscribe to an event we do not yet emit, the subscription is accepted but never fires. The list above is the source of truth — new event types will be added over time and announced in the Changelog.

Subscribe an endpoint

Webhook management lives under the Connect API and requires API key authentication (JWT is rejected for these routes). Up to 10 active endpoints per organisation are allowed.

curl -X POST https://api.efakturuj.sk/api/v1/connect/webhooks \
  -H 'X-Api-Key: efk_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://hooks.example.sk/efakturuj",
    "events": "invoice.delivered,invoice.received,invoice.rejected,invoice.fs_acknowledged,invoice.fs_rejected",
    "description": "Production ERP integration"
  }'

events is a comma-separated string. The default (when you omit events) is invoice.delivered,invoice.rejected,invoice.fs_acknowledged,invoice.fs_rejected: outbound delivery, rejection and both Finančná správa verdicts. Add invoice.received when you also want inbound invoices. Endpoints created before invoice.fs_rejected existed keep their stored list; add the event yourself.

The url must use http or https, and its host must resolve to public internet addresses only. A URL whose host resolves to a private, loopback or link-local address (for example localhost, 10.x.x.x or 169.254.169.254), or does not resolve at all, is refused with 422 and "error": "webhook_url_not_allowed". The check runs again before every delivery attempt: an attempt to an address that is no longer public is recorded as failed and not retried. Redirects are not followed, so a 3xx answer counts as a failed delivery.

The response includes the plaintext signing secret exactly once:

{
  "id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
  "url": "https://hooks.example.sk/efakturuj",
  "events": "invoice.delivered,invoice.received,invoice.rejected,invoice.fs_acknowledged,invoice.fs_rejected",
  "is_active": true,
  "description": "Production ERP integration",
  "created_at": "2026-05-06T10:00:00Z",
  "secret": "whsec_..."
}

Save the secret immediately — only its SHA-256 hash is persisted on our side. Subsequent reads (GET /connect/webhooks) will not return it.

Other endpoints:

  • GET /connect/webhooks — list endpoints
  • DELETE /connect/webhooks/{webhook_id} — deactivate (soft delete)
  • POST /connect/webhooks/{webhook_id}/rotate-secret — issue a new signing secret
  • POST /connect/webhooks/{webhook_id}/test — fire a synthetic test.ping event
  • GET /connect/webhooks/{webhook_id}/deliveries — last 50 delivery attempts

Event payload

Two envelope shapes are in use today; the table above says which one an event uses. Both are JSON objects with a stable id you dedup on.

efk.event.v1 (used by invoice.fs_acknowledged and invoice.fs_rejected, and by invoice.created, invoice.validated, invoice.validation_failed and invoice.queued when you subscribe to them):

{
  "schema": "efk.event.v1",
  "id": "evt_3f2c9a1e0b6d4c7e8f9a0b1c2d3e4f50",
  "type": "invoice.fs_acknowledged",
  "occurred_at": "2026-09-21T10:00:01.234567Z",
  "organisation_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
  "workspace_id": null,
  "resource": { "type": "invoice", "id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e" },
  "data": {
    "invoice_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
    "invoice_number": "FA2026052",
    "direction": "sent",
    "supplier_vat_id": "SK2020123456",
    "provider_document_id": null,
    "reporter_role": "C2",
    "state": "acknowledged",
    "tdd_uuid": "…",
    "mls_response_code": "AP",
    "reasons": null,
    "acknowledged_at": "2026-09-21T10:00:00+00:00",
    "rejected_at": null
  }
}
  • id is evt_ plus 32 hex characters, the same value as the X-Webhook-Event-Id header; retries repeat it.
  • type is the event-type string.
  • resource names the object the event is about: { "type": "invoice", "id": … } for an invoice issued through the API or the portal; { "type": "c5_submission", "id": … } for a SAPI-SK document without a mirror invoice, in which case data.invoice_id is null and data.provider_document_id is the SAPI providerDocumentId.
  • data for the two Finančná správa events: invoice_id (nullable), invoice_number, direction (sent or received, the same words as the partner document API) and supplier_vat_id (null only when there is no mirror invoice), provider_document_id (SAPI documents), reporter_role (C2 when you sent the invoice, C3 when you received it), state, tdd_uuid, mls_response_code, acknowledged_at and rejected_at (one of them set); reasons is FS's structured findings (description, issues[]) on invoice.fs_rejected and null on invoice.fs_acknowledged.
  • Every exchanged invoice has two copies, one per company, each with its own invoice_id, and each side's filing raises its own event. Match a sent event by invoice_number; match a received one by supplier_vat_id plus invoice_number, because invoice numbers are unique only per supplier.

Legacy flat envelope (used by invoice.delivered, invoice.received, invoice.rejected and test.ping):

{
  "id": "9c1f...",
  "event": "invoice.delivered",
  "created_at": "2026-05-06T10:00:01.234567+00:00",
  "data": {
    "invoice_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e"
  }
}
  • id is a UUIDv4 unique to the logical event. Use it for idempotency (see below).
  • event is one of the event-type strings above.
  • created_at is an ISO 8601 timestamp with timezone.
  • data is event-specific and always contains invoice_id; additional keys may appear over time without a major-version bump.

data of invoice.received:

{
  "invoice_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e",
  "source": "peppol",
  "invoice_number": "FA2026052",
  "supplier_vat_id": "SK2020123456",
  "message_id": "4b8f2c7e-1d3a-4f5b-9c6d-7e8f9a0b1c2d",
  "provider_document_id": "efk_sapi_in_8996b481a5de50cea57f368b144d68a2"
}
  • message_id is the Peppol message identifier (the SBDH InstanceIdentifier), the same value as peppol_message_id on the invoice detail and as metadata.documentId on the SAPI-SK receive detail.
  • provider_document_id is the SAPI-SK receive handle: pass it straight to GET /sapi/document/receive/{documentId} and POST /sapi/document/receive/{documentId}/acknowledge. It is null when SAPI-SK holds no copy of the document (for example a company without a workspace), so check it before calling SAPI-SK.

The two shapes will converge on efk.event.v1 in a later release; until then read type when present, else event.

Each delivery also carries these headers:

HeaderPurpose
efk-signatureRecommended v2 signature: t=<unix>,v1=<hex HMAC>.
X-Webhook-SignatureLegacy HMAC-SHA256 signature retained for one deprecation cycle.
X-Webhook-EventEvent type; mirrors type (efk.event.v1) or event (legacy).
X-Webhook-DeliveryUUID of this individual delivery attempt; changes on every retry.
X-Webhook-Event-IdStable event id from the payload (id); for efk.event.v1 events it is the evt_… value.

Body is Content-Type: application/json and is UTF-8 encoded with no whitespace between separators (the Python equivalent is json.dumps(..., separators=(",", ":"))). Verify the signature against the raw body bytes as received — do not re-serialise.

Signature verification

Use efk-signature for new integrations. It is an HMAC-SHA256 signature keyed with the plaintext whsec_... secret returned when you created or rotated the webhook. The signed string is {timestamp}.{raw_body}, where timestamp is the Unix time from the t= field. Reject signatures outside a ±300 second tolerance.

X-Webhook-Signature is a legacy header keyed with the stored SHA-256 hash of the secret. It remains during the deprecation cycle only; rotate old endpoints to receive efk-signature.

Python

import hashlib
import hmac
import time

SECRET = "whsec_PLAINTEXT_FROM_SUBSCRIPTION"

def verify(raw_body: bytes, efk_signature: str) -> bool:
    parts = dict(item.split("=", 1) for item in efk_signature.split(","))
    ts = int(parts["t"])
    if abs(int(time.time()) - ts) > 300:
        return False
    expected = hmac.new(
        SECRET.encode(),
        f"{ts}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Node.js

import crypto from "node:crypto";

const SECRET = "whsec_PLAINTEXT_FROM_SUBSCRIPTION";

function verify(rawBody, efkSignature) {
  const parts = Object.fromEntries(efkSignature.split(",").map((p) => p.split("=")));
  const ts = Number(parts.t);
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false;
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${ts}.`)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(parts.v1, "hex"),
  );
}

PHP

<?php
$secret = 'whsec_PLAINTEXT_FROM_SUBSCRIPTION';

function verify(string $rawBody, string $efkSignature, string $secret): bool {
    $parts = [];
    foreach (explode(',', $efkSignature) as $part) {
        [$key, $value] = explode('=', $part, 2);
        $parts[$key] = $value;
    }
    $ts = (int) $parts['t'];
    if (abs(time() - $ts) > 300) return false;
    $expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

Always use a constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual, hash_equals) — never ==.

Retry policy

Delivery is queued to Celery's standard queue with the following behaviour:

  • Timeout per attempt: 10 seconds.
  • Success window: any 2xx response (200–299).
  • Failure triggers retry: non-2xx response, connection error, or timeout.
  • Max attempts: 5 (the initial delivery + 4 retries).
  • Backoff: exponential, 5^attempt seconds between attempts — the next retry fires after 5s, then 25s, 125s, 625s, 3125s.
  • After 5 failed attempts the event is dropped from the queue. The attempt history remains visible in GET /connect/webhooks/{webhook_id}/deliveries and we do not re-emit the event automatically; reconcile by fetching the invoice via GET /companies/{company_id}/invoices/{id} if your handler missed an event.

Every attempt is recorded as an append-only row in the webhook_deliveries table, so you have a complete audit trail of what we tried, when, and what status we received back.

Idempotency on your side

Because we may retry up to 5 times, your subscriber will see the same event id more than once. Build your handler around the rule:

The first time you process event.id, do the work. Every subsequent time, return 200 immediately.

A minimum implementation:

def handle(event):
    if seen_recently(event["id"]):
        return 200
    do_work(event)
    mark_seen(event["id"], ttl_days=14)
    return 200

Two weeks of dedup history is plenty — the maximum gap between the initial attempt and the final retry is ~62 minutes.

Return a 2xx response only after the work is durable (committed to your database / queued to your own background job). If you 200 too early and then crash, eFakturuj will not redeliver.

Troubleshooting

  • Signature mismatch every time — most often the raw request body changed before verification, the wrong secret was used, or the timestamp is outside the ±300 second tolerance.
  • Body has unexpected whitespace — make sure you're verifying against the raw request body before any framework middleware re-serialises it. Express needs express.raw(); FastAPI needs await request.body() not await request.json().
  • Receiver gets retries even after returning 200 — confirm the status code is in the 2xx range; we treat 3xx as failure.

See also

  • API reference → Webhooks — full schemas for POST /connect/webhooks, GET /connect/webhooks/{webhook_id}/deliveries.
  • Sandbox — sandbox events fire through the same pipeline, so signature verification can be developed end-to-end without a real invoice.