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.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 /webhooks/{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/webhooks \
  -H 'X-Api-Key: efk_live_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://hooks.example.sk/efakturuj",
    "events": "invoice.delivered,invoice.rejected,invoice.fs_acknowledged",
    "description": "Production ERP integration"
  }'

events is a comma-separated string. The default subscribes to all three invoice events.

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.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 /webhooks) will not return it.

Other endpoints:

  • GET /webhooks — list endpoints
  • DELETE /webhooks/{id} — deactivate (soft delete)
  • POST /webhooks/{id}/test — fire a synthetic test.ping event
  • GET /webhooks/{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
X-Webhook-SignatureHMAC-SHA256 of the raw body — see below.
X-Webhook-EventEvent type, mirrors the event field.
X-Webhook-DeliveryUUID of this individual delivery attempt; changes on every retry.

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

eFakturuj signs every webhook with HMAC-SHA256 and sends the hex digest in X-Webhook-Signature.

Important quirk. The HMAC key is not the plaintext secret we returned at subscription time — it is the SHA-256 hex digest of that secret. We never store the plaintext, so the hash is what we use as the shared key for signing. After receiving the secret once, compute sha256(secret).hexdigest() and store that as your verification key.

Python

import hashlib
import hmac

# Compute once, at subscription time, from the plaintext secret you saved.
SIGNING_KEY = hashlib.sha256(b"whsec_PLAINTEXT_FROM_SUBSCRIPTION").hexdigest()

def verify(raw_body: bytes, signature_header: str) -> bool:
    expected = hmac.new(
        SIGNING_KEY.encode(),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Node.js

import crypto from "node:crypto";

// Compute once, at subscription time.
const SIGNING_KEY = crypto
  .createHash("sha256")
  .update("whsec_PLAINTEXT_FROM_SUBSCRIPTION")
  .digest("hex");

function verify(rawBody, signatureHeader) {
  const expected = crypto
    .createHmac("sha256", SIGNING_KEY)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signatureHeader, "hex"),
  );
}

PHP

<?php
// Compute once, at subscription time.
$signingKey = hash('sha256', 'whsec_PLAINTEXT_FROM_SUBSCRIPTION');

function verify(string $rawBody, string $signatureHeader, string $signingKey): bool {
    $expected = hash_hmac('sha256', $rawBody, $signingKey);
    return hash_equals($expected, $signatureHeader);
}

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 /webhooks/{id}/deliveries and we do not re-emit the event automatically; reconcile by fetching the invoice via GET /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 you're hashing the plaintext secret instead of using the sha256 digest as the HMAC key. Re-read the quirk box above.
  • 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 /webhooks, GET /webhooks/{id}/deliveries.
  • Sandbox — sandbox events fire through the same pipeline, so signature verification can be developed end-to-end without a real invoice.