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 acknowledged by the Slovak Tax Authority. 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 fires
invoice.deliveredThe receiving Peppol Access Point ACKed the SBDH wrapper for your invoice.
invoice.receivedAn inbound Peppol invoice was received for your company.
invoice.rejectedThe receiver returned a Peppol MLS rejection (validation, addressing, or business-rule failure).
invoice.fs_acknowledgedFinančná správa accepted the C5 copy of the invoice.
test.pingSynthetic event fired by POST /connect/webhooks/{webhook_id}/test so you can develop your handler without sending a real invoice.

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",
    "description": "Production ERP integration"
  }'

events is a comma-separated string. The default subscribes to outbound delivery, rejection, and FS-acknowledgement events; add invoice.received when you also want inbound invoices.

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",
  "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

Every event has the same envelope:

{
  "id": "9c1f...",
  "event": "invoice.delivered",
  "created_at": "2026-05-06T10:00:01.234567+00:00",
  "data": {
    "invoice_id": "8f5a1c20-1f6e-4f6c-9f5e-2f5a1c201f6e"
  }
}

Fields:

  • id — UUIDv4 unique to this delivery attempt's logical event. Use it for idempotency (see below).
  • event — one of the event-type strings above.
  • created_at — ISO 8601 timestamp with timezone.
  • data — event-specific payload. Always contains invoice_id; additional keys may appear over time without a major-version bump.

Each delivery also carries three 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 the event field.
X-Webhook-DeliveryUUID of this individual delivery attempt; changes on every retry.
X-Webhook-Event-IdStable event id from the payload, when available.

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 (200299).
  • 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.