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.
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. |
data |
object | Event-specific payload. |
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": "xrechnung",
"profile": "xrechnung",
"status": "completed",
"input_format": "ubl",
"output_format": null,
"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 | null | Target standard, when applicable. |
profile |
string | null | Target profile, when applicable. |
status |
string | completed or failed. |
input_format |
string | null | Detected or supplied input format. |
output_format |
string | null | Output format, when applicable. |
processing_ms |
number | null | Server-side processing time in milliseconds. |
error_message |
string | Present on job.failed only. |
validation |
object | Present when the job produced validation findings: valid, error_count, warning_count, and optional format / profile_detected. |
Quota data
{
"period": { "year": 2026, "month": 6 },
"used": 8000,
"limit": 10000,
"threshold": 0.8
}
| Field | Type | Notes |
|---|---|---|
period |
object | The calendar year and month (UTC) the usage applies to. |
used |
number | Requests consumed in the period. |
limit |
number | The plan’s monthly limit for the period. |
threshold |
number | Present on quota.threshold_reached only (0.8). |
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.
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.