Webhooks overview
Webhooks push invoice lifecycle events to your server as they happen, so you do not have to poll invoice state. When an invoice reaches a milestone, the product creates an event and delivers it to every enabled webhook endpoint in the organization that subscribes to that event type. You configure those endpoints through the control plane and inspect delivery outcomes in Delivery records.
How delivery works
When a product event is created, one durable delivery is planned for each subscribed, enabled endpoint in the organization. Delivery happens asynchronously in the background and is bounded per tenant, so one organization's slow receiver cannot starve delivery for others.
Before each attempt, delivery re-resolves and re-validates the endpoint's current URL and pins the connection to the validated address set for that attempt. If the endpoint has been disabled or deleted before sending, the delivery is recorded as endpoint_disabled and no HTTP request is made. If the target has become unsafe, the delivery is recorded as failed and, again, no request is sent. Because delivery can be retried, the same event may arrive more than once; consumers must be idempotent and de-duplicate by event.id.
Request format
Each delivery is an HTTP POST with Content-Type: application/json. The body is the raw Event JSON. Three headers accompany every request:
POST /ksef/webhooks HTTP/1.1Content-Type: application/jsonKsef-Event-Id: evt_V3sGDb5VXxLxzGzvYVw0PwKsef-Timestamp: 2026-06-01T10:18:30ZKsef-Signature: v1=<lowercase-hex-hmac>Ksef-Event-Id mirrors the event's id, Ksef-Timestamp is the signing timestamp, and Ksef-Signature carries the HMAC signature described below.
The Ksef-Signature header
The signature lets you confirm a request genuinely came from Emfakt and was not modified. It is computed as an HMAC-SHA256 over the string Ksef-Timestamp + "." + raw JSON request body, using the endpoint's whsec_... signing secret as the key, and is encoded as v1=<lowercase-hex-hmac>.
To verify a request, read the raw request body exactly as received do not parse and re-serialize it, because re-serialization can change the bytes and invalidate the signature. Recompute the HMAC with your stored signing secret and compare it to the header using a constant-time comparison. You should also reject requests whose Ksef-Timestamp is stale, and return a 2xx status only after the event has been durably processed.
import crypto from 'node:crypto'// rawBody must be the exact bytes received, before JSON parsing.function isValidSignature(rawBody, headers, signingSecret) { const timestamp = headers['ksef-timestamp'] const received = headers['ksef-signature'] // "v1=<lowercase-hex-hmac>" const expected = 'v1=' + crypto .createHmac('sha256', signingSecret) .update(`${timestamp}.${rawBody}`) .digest('hex') const a = Buffer.from(received) const b = Buffer.from(expected) return a.length === b.length && crypto.timingSafeEqual(a, b)}Delivery retries
Delivery does not follow redirects. Every 3xx response is treated as a failed delivery, because a signed payload must not be forwarded to an unexpected host. A retryable 429, 5xx, timeout, connection failure, or unexpected sender failure schedules another attempt with simple backoff, while a non-retryable 3xx or 4xx (other than 429) marks the delivery failed. Retry timing is an operational detail, not a guaranteed schedule.
Because retries can produce duplicate deliveries, always de-duplicate by event.id rather than by delivery ID, and make your handler safe to run more than once for the same event.
There is no public event-list endpoint and no event-replay or webhook test-send API. The only ways to observe delivery are your own endpoint and the sanitized Delivery records.