eliDocs
API Reference

Webhooks

Subscribe to signed event notifications for jobs, quota, and subscription changes — set up an endpoint, verify signatures, and handle retries.

Beliq posts a signed JSON payload to your endpoint when something happens on your account — an API job finishes, you approach a quota limit, or your subscription changes. Each delivery is an HTTP POST carrying an HMAC signature you verify with the endpoint’s secret.

Setting up an endpoint

Setting up an endpoint

Webhook endpoints are managed in the dashboard under Webhooks. To add one:

  1. Enter the HTTPS URL that should receive deliveries.
  2. Choose which events to subscribe to.
  3. Beliq generates a signing secret (prefixed whsec_), shown once — copy it immediately and store it securely.

From the same page you can toggle an endpoint active or inactive, rotate its secret, and inspect the delivery log — every attempt, its status code, the response snippet, and any retries.

Endpoint URLs must be public https:// addresses. Private, loopback, and link-local targets are rejected, and the hostname is resolved at save time so a public name pointing at a private address is refused too.

Use the default port. Only ports 80 and 443 are accepted, and only 443 is actually reachable: our egress permits nothing else. An endpoint saved as https://example.com:80/hooks therefore saves cleanly and then fails every delivery until the retry schedule is exhausted. Leave the port off the URL.

The event envelope

The event envelope

Every delivery shares the same envelope. Only data changes by event type.

{
  "id": "evt_3f9a1c0b8d7e4f2a9b6c5d4e3f2a1b0c",
  "type": "job.completed",
  "created_at": "2026-06-21T10:21:58.000Z",
  "organization_id": "8b1d2c3e-4f5a-6b7c-8d9e-0f1a2b3c4d5e",
  "livemode": true,
  "data": { }
}
Field Type Description
id string Event id, evt_ followed by 32 hex characters. Stable across retries — use it as your idempotency key.
type string The event type (see below).
created_at string ISO 8601 UTC timestamp of when the event was created.
organization_id string UUID of the organization the event belongs to.
livemode boolean true for production events, false for test events. Every job, quota and subscription event is true today — see Test mode.
data object Event-specific payload.

Test mode and livemode

A sandbox key (blq_test_) records its call in your history and stops there: it produces no webhook at all. Every one of the ten job, quota and subscription events below is therefore emitted with livemode: true, and there is currently no “send test event” control in the dashboard. Handle the field, but do not wait for a false on these events, and do not use it as your live/test switch — it will not fire.

To exercise a handler end to end today, use a live key against the free monthly allowance:

  • job.completed and job.validation_failed — send a document to /v1/validate. A deliberately invalid one produces both.
  • quota.threshold_reached / quota.exceeded — only reachable by actually consuming the plan’s monthly documents.
  • job.failed and the subscription.* events — cannot be triggered on demand. They mean a Beliq-side fault or a real billing transition.

The transmission events are the exception: they carry the transmission’s own mode, so a test-mode delivery does emit livemode: false.

Event types

Event types

Event Fires when data
job.completed A /v1/* job finishes successfully (2xx). JobData
job.failed A job fails due to an error on Beliq’s side. JobData
job.validation_failed A job produces error-level validation findings. Also sent alongside job.completed when a validate request returns valid: false. JobData
quota.threshold_reached Monthly usage crosses 80% of your plan limit. QuotaData
quota.exceeded Monthly usage reaches your plan limit. QuotaData
subscription.activated A plan becomes active. SubscriptionData
subscription.updated The plan changes (upgrade or downgrade). SubscriptionData
subscription.canceled The subscription is scheduled to end at the period end. SubscriptionData
subscription.ended Access to a paid plan ends. SubscriptionData
subscription.payment_failed A charge fails (dunning). SubscriptionData

Job data

{
  "job_id": "f2a1b0c9-d8e7-4f6a-5b4c-3d2e1f0a9b8c",
  "operation": "validate",
  "standard": "ubl",
  "profile": "xrechnung",
  "status": "completed",
  "input_format": "xml",
  "output_format": "json",
  "processing_ms": 142,
  "validation": {
    "valid": false,
    "error_count": 2,
    "warning_count": 1,
    "format": "ubl",
    "profile_detected": "xrechnung"
  }
}
Field Type Notes
job_id string Identifier for the job.
operation string generate, validate, parse, or convert.
standard string Target standard for generate and convert; the detected format for validate and parse. unknown when the job failed before detection.
profile string Target profile for generate and convert; the detected profile for validate and parse. unknown when the job failed before detection.
status string completed or failed.
input_format string Envelope the request carried, not the e-invoice format: json, xml, or pdf. The e-invoice format is standard.
output_format string Envelope of the response: json, xml, or pdf. validate and parse return json.
processing_ms number | null Server-side processing time in milliseconds.
error_message string? The failure reason, when there is one. Present on job.failed, and on job.validation_failed when the job also recorded a message. Absent otherwise — treat it as optional on every job event.
validation object Present when the job produced validation findings: valid, error_count, warning_count, and optional format / profile_detected.

Quota data

{
  "period_start": "2026-06-20T09:00:00.000Z",
  "period_end": "2026-07-20T09:00:00.000Z",
  "used": 8000,
  "limit": 10000,
  "threshold": 0.8
}
Field Type Notes
period_start string ISO 8601 UTC start of the quota window the usage applies to.
period_end string ISO 8601 UTC, exclusive: when the counter resets to zero.
used number Requests consumed in the window.
limit number The plan’s allowance for the window.
threshold number Present on quota.threshold_reached only (0.8).

The quota window is one month wide and anchored on your billing day, so it is a calendar month only if your subscription renews on the 1st. An annual subscription still gets monthly windows: the allowance is per month, not a single yearly pool.

Subscription data

{
  "plan": "Growth",
  "interval": "annual",
  "period_start": "2026-06-01T00:00:00.000Z",
  "period_end": "2027-06-01T00:00:00.000Z",
  "currency": "EUR",
  "previous_plan": "Starter",
  "effective_at": "2026-06-21T10:21:58.000Z"
}
Field Type Notes
plan string | null Plan name after the event; null when no plan is active (for example on subscription.ended).
interval string | null monthly or annual.
period_start string | null ISO 8601 UTC start of the current billing period.
period_end string | null ISO 8601 UTC end of the current billing period.
currency string | null Billing currency.
cancel_at_period_end boolean Present on subscription.canceled.
previous_plan string | null Present on subscription.updated and subscription.ended.
effective_at string | null Present on subscription.updated — when the change takes effect (now for an upgrade, period end for a scheduled downgrade).
Transmission events (preview)

Transmission events (preview)

Subscribe to these to follow a delivery without polling. Each carries TransmissionData.

Event Fires when data
transmission.queued A document is validated and durably queued for delivery. TransmissionData
transmission.submitted A provider acknowledged the submission. TransmissionData
transmission.cleared The network confirmed technical acceptance and a proof was captured. TransmissionData
transmission.delivered Delivery to the recipient is confirmed. TransmissionData
transmission.rejected The network or recipient refused the document. TransmissionData
transmission.failed Delivery failed permanently after retries. TransmissionData
transmission.at_risk A delivery deadline is threatened or breached. TransmissionData
transmission.received An inbound document has been validated and is available to read. TransmissionData

Transmission data

{
  "transmission_id": "txn_9f3c2a1b8e",
  "direction": "outbound",
  "network": "peppol",
  "status": "delivered",
  "at_risk": false,
  "counterparty": { "scheme": "0208", "id": "9876543210" },
  "deadline_at": "2026-04-20T00:00:00.000Z"
}
Field Type Notes
transmission_id string The transmission this event is about.
direction string outbound or inbound.
network string The network the document travels on.
status string The status after this event. See delivery status.
at_risk boolean true when a delivery deadline is threatened or breached.
counterparty object | null The { scheme, id } at the other end.
deadline_at string | null ISO 8601 delivery deadline, when set.
Request headers

Request headers

Header Value
X-Beliq-Signature t=<unix-seconds>,v1=<hmac-sha256 hex> — see Verifying signatures.
X-Beliq-Event The event type.
X-Beliq-Event-Id The event id (evt_…). Stable across retries; use it for idempotency.
X-Beliq-Delivery-Id Identifier for this individual delivery attempt record.
User-Agent beliq-webhooks/1.0
Content-Type application/json
Verifying signatures

Verifying signatures

Each request carries an X-Beliq-Signature header with a Unix timestamp and an HMAC-SHA256 signature computed over <timestamp>.<body> using your endpoint’s secret:

X-Beliq-Signature: t=1700000000,v1=4f3c...

Verify by parsing the header, recomputing the HMAC over the raw request body, comparing in constant time, and rejecting timestamps outside a freshness window (300 seconds is recommended):

verify-webhook.ts
import crypto from "node:crypto";

const FRESHNESS_TOLERANCE_SECONDS = 300;

export function verifyWebhookSignature(
  body: Buffer,
  header: string | undefined,
  secret: string,
): boolean {
  if (!header) return false;

  const match = header.match(/^t=(\d+),v1=([0-9a-f]+)$/);
  if (!match) return false;

  const timestamp = Number(match[1]);
  const provided = Buffer.from(match[2], "hex");

  const nowSeconds = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSeconds - timestamp) > FRESHNESS_TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest();

  if (provided.length !== expected.length) return false;
  return crypto.timingSafeEqual(provided, expected);
}

The signed input is the raw request body — verify before any JSON parsing or normalization. The body must be the exact bytes the request carried; reserializing a parsed object produces a different HMAC.

Idempotency

Idempotency

Deduplicate on X-Beliq-Event-Id (the envelope id): store delivered ids and skip repeats. The id is the same across all retries of an event; the timestamp t is re-signed on each attempt, so never treat (id, t) as a uniqueness pair — dedup on the id alone.

A manual resend carries the original event id. Pressing Resend on a delivery in the dashboard replays the same evt_…, so a handler following the advice above will correctly recognise it as a duplicate and discard it — while the delivery log records your 2xx and shows the resend as successful. That is the intended behaviour of both halves and it is a confusing pair to meet for the first time during an incident. When you are resending because your handler dropped the event, clear that id from your dedup store first, or reconcile from the API rather than the replay.

Ordering

Ordering

Deliveries are not ordered. Events are dispatched as they are produced and each retries on its own schedule, so a job.completed from a later request can arrive before an earlier one, and a retried event can arrive after events created long after it. Do not infer sequence from arrival order: use the envelope’s created_at, and treat each event as a statement about its own subject rather than a step in a stream.

Delivery and retries

Delivery and retries

Beliq attempts the first delivery immediately, then retries failures up to 7 attempts total with increasing backoff:

Attempt Delay after previous
1 immediate
2 1 minute
3 5 minutes
4 30 minutes
5 2 hours
6 8 hours
7 24 hours

After the final attempt the delivery is marked failed. Each attempt has a 10-second timeout.

  • 2xx — delivery succeeds.
  • 3xx — treated as a failure; redirects are not followed. Register the final URL directly.
  • 4xx / 5xx or a connection error — retried until the schedule is exhausted.
  • An inactive or removed endpoint stops delivery immediately.

Return a 2xx status as soon as you have accepted the event; do the work afterward. Inspect past deliveries and retry state in the dashboard delivery log.

When deliveries stop

When deliveries stop

An endpoint stops receiving events in three ways, and all three lose the events that occur while it is stopped.

You pause it. Pausing takes effect immediately, including for deliveries already scheduled to retry.

Beliq disables it. When a delivery exhausts the full seven-attempt schedule — roughly 35 hours of failures — the endpoint is disabled automatically. The organization’s owners and admins are emailed, each subject to their own notification preferences. The dashboard shows such an endpoint as auto-disabled together with the reason, distinct from a pause you performed, and re-enabling it clears that state.

You delete it. Deliveries stop and the endpoint’s delivery history goes with it.