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:
| Event | When it fires | Envelope |
|---|---|---|
invoice.delivered | The receiving Peppol Access Point ACKed the SBDH wrapper for your invoice. | legacy |
invoice.received | An inbound Peppol invoice was received for your company. | legacy |
invoice.rejected | The receiver returned a Peppol MLS rejection (validation, addressing, or business-rule failure). | legacy |
invoice.fs_acknowledged | Finančná správa confirmed the C5 tax-data copy of your sent or received invoice (positive MLS from C5). | efk.event.v1 |
invoice.fs_rejected | Finanč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.ping | Synthetic 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):
| Event | When it fires |
|---|---|
client.linked | A pre-registered client's FS designation arrived and the company was auto-linked to your partner account (mass enrolment). |
client.activated | The client's Peppol participant registration was verified on the network — the company is fully operational. |
client.offboarding | Offboarding started for a managed client: the company is frozen and the 90-day retention clock is running (offboarding). |
client.purged | The retention window ended and the client's business data was deleted; only the company tombstone remains. |
export.completed | A 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 endpointsDELETE /connect/webhooks/{webhook_id}— deactivate (soft delete)POST /connect/webhooks/{webhook_id}/rotate-secret— issue a new signing secretPOST /connect/webhooks/{webhook_id}/test— fire a synthetictest.pingeventGET /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
}
}
idisevt_plus 32 hex characters, the same value as theX-Webhook-Event-Idheader; retries repeat it.typeis the event-type string.resourcenames 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 casedata.invoice_idisnullanddata.provider_document_idis the SAPIproviderDocumentId.datafor the two Finančná správa events:invoice_id(nullable),invoice_number,direction(sentorreceived, the same words as the partner document API) andsupplier_vat_id(null only when there is no mirror invoice),provider_document_id(SAPI documents),reporter_role(C2when you sent the invoice,C3when you received it),state,tdd_uuid,mls_response_code,acknowledged_atandrejected_at(one of them set);reasonsis FS's structured findings (description,issues[]) oninvoice.fs_rejectedandnulloninvoice.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 asentevent byinvoice_number; match areceivedone bysupplier_vat_idplusinvoice_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"
}
}
idis a UUIDv4 unique to the logical event. Use it for idempotency (see below).eventis one of the event-type strings above.created_atis an ISO 8601 timestamp with timezone.datais event-specific and always containsinvoice_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_idis the Peppol message identifier (the SBDH InstanceIdentifier), the same value aspeppol_message_idon the invoice detail and asmetadata.documentIdon the SAPI-SK receive detail.provider_document_idis the SAPI-SK receive handle: pass it straight toGET /sapi/document/receive/{documentId}andPOST /sapi/document/receive/{documentId}/acknowledge. It isnullwhen 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:
| Header | Purpose |
|---|---|
efk-signature | Recommended v2 signature: t=<unix>,v1=<hex HMAC>. |
X-Webhook-Signature | Legacy HMAC-SHA256 signature retained for one deprecation cycle. |
X-Webhook-Event | Event type; mirrors type (efk.event.v1) or event (legacy). |
X-Webhook-Delivery | UUID of this individual delivery attempt; changes on every retry. |
X-Webhook-Event-Id | Stable 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^attemptseconds 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}/deliveriesand we do not re-emit the event automatically; reconcile by fetching the invoice viaGET /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 needsawait request.body()notawait 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.