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:
| Event | When it fires |
|---|---|
invoice.delivered | The receiving Peppol Access Point ACKed the SBDH wrapper for your invoice. |
invoice.rejected | The receiver returned a Peppol MLS rejection (validation, addressing, or business-rule failure). |
invoice.fs_acknowledged | Finančná správa accepted the C5 copy of the invoice. |
test.ping | Synthetic 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 endpointsDELETE /webhooks/{id}— deactivate (soft delete)POST /webhooks/{id}/test— fire a synthetictest.pingeventGET /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 containsinvoice_id; additional keys may appear over time without a major-version bump.
Each delivery also carries three headers:
| Header | Purpose |
|---|---|
X-Webhook-Signature | HMAC-SHA256 of the raw body — see below. |
X-Webhook-Event | Event type, mirrors the event field. |
X-Webhook-Delivery | UUID 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
secretwe 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, computesha256(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 (
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 /webhooks/{id}/deliveriesand we do not re-emit the event automatically; reconcile by fetching the invoice viaGET /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
sha256digest 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 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 /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.