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
Webhook endpoints are managed in the dashboard under Webhooks. To add one:
- Enter the HTTPS URL that should receive deliveries.
- Choose which events to subscribe to.
- 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
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.completedandjob.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.failedand thesubscription.*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 | 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)
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
| 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
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):
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
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
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
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
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.