Webhooks & events
Register an endpoint URL to receive a signed JSON payload the moment something changes — an invoice status change, an inbound Peppol delivery, a Spain SII reporting-batch deadline — instead of polling. One webhook subscription covers events from every product on your account.
Register a webhook
Register an HTTPS endpoint with POST /v1/webhooks. A delivery is retried automatically, up to 10 attempts in total, until your endpoint returns a 2xx response; each attempt times out after 10 seconds. The signing secret is generated by Clearvo and returned once, in the 201 response — store it then.
The endpoint must be on a public address: no credentials in the URL, and no localhost, internal or private addresses (a domain name that resolves to one is refused as well). Clearvo does not follow redirects, so register the final URL.
curl https://api.clearvo.io/v1/webhooks \
-X POST \
-H "x-api-key: csk_live_••••••••••••" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/clearvo",
"events": ["invoice.accepted", "invoice.rejected"]
}'
| Body field | Type | Description |
|---|---|---|
| url | string | Destination URL. Must be https://. Required |
| events | string[] | Event names from the table below. Defaults to ["*"] (every event except invoice.status_changed, which you list by name). An unknown name returns 400 listing the valid ones. Optional |
| statuses | string[] | Only used with invoice.status_changed. The statuses you want to hear about, matched on the new status, for example ["WAITING_FOR_YOU"]. Leave it out or send an empty list to receive every status change. An unknown status returns 400 listing the valid ones, and so does sending statuses without invoice.status_changed in events. Optional |
| entityId | string | Only for an organisation-level key restricted to specific entities: which of its allowed entities this webhook targets (403 if missing or not allowed). An unrestricted organisation key omits it and the webhook covers every entity; an entity-level key is always pinned to its own entity. Optional |
There is no client-supplied secret. The 201 response contains id, url, events, statuses (an empty list when you did not choose any), entityId, createdAt and the generated secret (a whsec_… value). It cannot be retrieved again — to rotate it, delete the webhook and register a new one.
{
"id": "a1b2c3d4-...",
"url": "https://your-app.com/webhooks/clearvo",
"events": ["invoice.accepted", "invoice.rejected"],
"statuses": [],
"entityId": null,
"createdAt": "2026-10-05T09:00:00Z",
"secret": "whsec_••••••••••••••••••••••••"
}
Events
For new integrations, subscribe to invoice.status_changed and select the statuses you need. The existing events below keep working unchanged. If you subscribe to both invoice.status_changed and a specific event such as invoice.accepted, one change produces two deliveries, each with its own x-taxually-delivery-id. Reminders about documents that are waiting for you are sent by email and shown in the dashboard, not as webhooks.
Subscribe to "*" to receive every event instead of naming each one — it also delivers events that have no individually subscribable name (for example invoice.delivered). The one exception is invoice.status_changed: "*" and the default do not include it, so list it by name. These are all the names events accepts:
| Event | Fires when |
|---|---|
invoice.status_changed | An invoice changed status, for any reason: a rule, the authority, us or you. Choose which statuses you receive with statuses when you register. The payload carries eventId (stable across delivery retries: dedupe on it), seq (increases with every status change of one invoice: order by it), from (empty for the first status), to, statusClass, terminal, nextStep and a reason (code, message, actor and, when you can act, fixUrl). See the e-invoice lifecycle. |
invoice.accepted | The authority cleared the invoice. |
invoice.rejected | The authority — or, for France, the customer — rejected the invoice. The payload carries rejectedBy (authority or customer) and a structured rejectionDetail. |
invoice.received | A new inbound invoice arrived from a supplier (Poland KSeF, France, Mexico, Germany, Peppol AS4, Italy SDI). Germany and Peppol AS4 receipts also carry intakeChannel, receivedAt, transactionDirection, validation, attachments and rendition; Mexico adds an mx block and Brazil a br block. |
invoice.summary_received | Brazil only: the first, summary-stage discovery of a received NF-e, before the full document is stored. The full document later fires invoice.received. |
invoice.duplicate | The submission matched an existing invoice. |
invoice.pending | The authority is still processing — no terminal outcome yet. |
invoice.undelivered | Italy: the recipient was unreachable and the 10-day retry period started. |
invoice.business_status_changed | A customer-lifecycle status transition was recorded on a received invoice (approved, disputed, paid, …), by you through PATCH /v1/invoices/{id}/business-status or reported back by the network. Carries businessStatus, canonicalBusinessStatus and previousBusinessStatus. |
accepted_with_errors | Spain SII: AEAT registered the invoice but flagged an admissible error (the invoice stays COMPLETED with reason ACCEPTED; the AEAT errors are in issues[]). |
invoice.accepted_with_errors | Spain SII: sent once per invoice when it first needs a correction. Carries recommendedAction (amend_report or cancel_report). |
invoice.amend_due | Spain SII: a later reminder that an invoice accepted with errors still needs its amendment. Carries recommendedAction. |
invoice.amended | Spain SII: an A1 amendment was accepted by AEAT. |
invoice.cancelled | Spain SII: a Baja (cancellation) was accepted by AEAT; the invoice moves to CANCELLED. |
invoice.report_due_soon | A reportable transaction is approaching its reporting deadline. |
invoice.report_overdue | A reportable transaction has passed its reporting deadline without being reported. |
invoice.reported | Portugal: AT accepted the communication of an issued invoice's data (a separate obligation from invoice issuance). |
invoice.report_rejected | Portugal: AT rejected that communication. |
reporting_batch.ready_for_review | Spain SII: a review-mode batch is frozen and waiting for your confirmation. |
reporting_batch.deadline_approaching | Spain SII: 2 and 1 Spanish business days before the batch's report-by date. |
reporting_batch.deadline_passed | Spain SII: the first business day after the report-by date, or when an automatic submission gave up past its deadline. |
reporting_batch.overdue_reminder | Spain SII: every business day after the deadline until the batch is submitted. Reporting-batch payloads also carry version, eventId, batchId, book, recordCount and daysOverdue. |
held_unmapped_decision | No compliance-mandate rule matched the transaction — held on our side, not a fault on yours. Carries holdReason and actionOwner. |
product.classification_changed | A product's suggested tax category or status changed (for example after a bulk reclassification). Carries productId, externalProductId, oldTaxCategorySlug, newTaxCategorySlug, newTaxProductStatus and classificationSource. |
ap.decision | An AP purchase calculation was held, over- or under-charged, or accepted with a warning (an ordinary in-tolerance accept does not fire it). Its payload is { event, calculationId, entityId, supplierRef, outcome, reasonCode, legalDelta, appliedDelta, warning, recordedAt } and does not use the invoice fields above. |
filing.enabled, filing.ready, filing.approved, filing.paused, filing.resumed, filing.pause_ending, filing.payment_due, filing.submitted, filing.filed, filing.skipped, filing.not_filed, filing.action_needed | A US sales tax return changed state (Filings). Payload: event, eventId, filingId, jurisdictionId, periodKey, filingStatus, dueDate, amountDue. See Filings. |
Every invoice-related payload, including invoice.status_changed, carries event, submissionId, country, referenceId, status, stage, reason, terminal, statusLabel, issues, customerResponse, updatedAt and entityId, plus the event-specific fields above; a field appears only when it has a value. Corrective invoices (Fix & Resubmit, credit notes) also carry correctsInvoiceId and relationType. Delivery also adds webhookId.
Effective 2026-09-21, webhook payload keys buyer*/buyerNotification/rejectedBy: 'buyer' are now customer*/customerNotification/'customer' — historical GET /v1/invoices, /v1/export, and transaction rows were rewritten in place to the new keys on deploy of this change, so there is nothing to migrate on your end.
Status changes: invoice.status_changed
One event covers every status change of an e-invoice: it is stored, held, released, retried, or answered by the authority. Register it by name and choose which new statuses you want with statuses. Leave statuses out, or send an empty list, to receive every change.
curl https://api.clearvo.io/v1/webhooks \
-X POST \
-H "x-api-key: csk_live_••••••••••••" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/clearvo",
"events": ["invoice.status_changed"],
"statuses": ["WAITING_FOR_YOU"]
}'
This is the payload your endpoint receives. It is deliberately lean: it tells you what changed and what to do next, and points to GET /v1/invoices/{id} for the full detail (rejection details and document links stay on the specific events).
{
"event": "invoice.status_changed",
"eventId": "evt_3f9a1c0b7d2e4a5b8c6d1e0f9a2b3c4d",
"seq": 1791363600001,
"id": "EI-PL-a1b2c3d4",
"submissionId": "EI-PL-a1b2c3d4",
"referenceId": "INV-2026-0042",
"country": "PL",
"documentType": "invoice",
"status": "WAITING_FOR_YOU",
"stage": null,
"reason": "NEEDS_INFO",
"previousStatus": "RECEIVED",
"from": "RECEIVED",
"to": "NEEDS_INFO",
"statusClass": "WAIT_MERCHANT",
"terminal": false,
"cause": {
"code": "MISSING_NIP",
"message": "The supplier NIP is missing.",
"actor": "rule",
"fixUrl": "/transactions/EI-PL-a1b2c3d4"
},
"nextStep": {
"actor": "merchant",
"message": "The supplier NIP is missing.",
"fixUrl": "/transactions/EI-PL-a1b2c3d4"
},
"at": "2026-10-05T09:00:00Z",
"updatedAt": "2026-10-05T09:00:00Z",
"entityId": "ent_8f3a..."
}
| Field | Description |
|---|---|
| id, submissionId | The invoice id. Both carry the same value; submissionId is kept so the field matches the other events. |
| referenceId, country, documentType | As on the other invoice events. referenceId is null when the document has none. |
| status, previousStatus | The new status and the status before the change. previousStatus is null for the first status of a document. from and to are kept for existing integrations; use status and previousStatus in new code. stage, reason, statusLabel, issues and customerResponse describe the new status, see Invoice statuses. cause says why it changed and who caused it. |
| statusClass | Who the document is now waiting for: IN_FLIGHT, WAIT_MERCHANT, WAIT_PLATFORM, DONE_OK, DONE_REJECTED or DONE_NA. |
| terminal | true when no further status change will happen. |
| reason | code and message (either can be null), actor (who caused the change: authority, rule, system, merchant or platform) and, when you can fix it yourself, fixUrl. |
| nextStep | actor (merchant, platform, authority or system), message and, when you can fix it yourself, fixUrl. null when nothing is outstanding. |
| at, updatedAt | When the change happened (ISO 8601). Both carry the same value. |
| entityId, organisationId | The entity. organisationId is present on partner and organisation webhooks. |
| test | true, only on a test event. |
Signing, the secret and the x-taxually-* headers are the same as for every other event.
Duplicates and ordering
- Dedupe on
eventId. It is the same on every delivery retry of one event, so a delivery you have already handled can be ignored. - Order by
seq.seqincreases with every status change of one invoice. Deliveries are retried and can arrive out of order: for each invoice, keep the highestseqyou have applied and ignore an event with a lower one.
Choosing statuses
- Tell me when I must act:
["WAITING_FOR_YOU"]. It waits for you until you fix it, andreasonsays exactly what is needed. - Tell me the final answer:
["COMPLETED", "REJECTED"]. - Everything: leave
statusesout.
The status names you can choose are RECEIVED, PENDING, WAITING_FOR_YOU, WAITING_FOR_PLATFORM, ACCUMULATED, COMPLETED, REJECTED, CANCELLED, SKIPPED and NOT_APPLICABLE. What each one means and who acts on it is in the e-invoice lifecycle.
The specific events (invoice.accepted, invoice.rejected and the rest) are unchanged and keep their payloads. A webhook subscribed to both gets both: one status change produces two deliveries with different x-taxually-delivery-id values. There are no webhook events for reminders, and no separate "needs attention" or "released" event: use statuses instead.
Verify the signature
Every delivery is signed with HMAC-SHA256 over {timestamp}.{body}, using the whsec_… secret returned when you registered the webhook. Reject any request whose signature doesn't match before processing the payload.
x-taxually-signature: sha256=5f3d...
x-taxually-timestamp: 1758067200
x-taxually-webhook-id: a1b2c3d4-...
x-taxually-delivery-id: d4c3b2a1-...
const crypto = require('crypto');
function isValidSignature(body, headers, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(`${headers['x-taxually-timestamp']}.${body}`)
.digest('hex');
return headers['x-taxually-signature'] === `sha256=${expected}`;
}
Test your endpoint
Send one signed test event to a webhook you registered, to check that your endpoint is reachable, verifies the signature and answers with a 2xx. The request is built and signed exactly like a real delivery and its JSON body carries "test": true, so you can ignore it in your own processing. It is sent once with no retries and nothing is stored; no real invoice changes.
POST /v1/webhooks/test with { "webhookId": "<id>", "event": "invoice.received", "invoiceId": "<optional>" }. event defaults to invoice.received and can be invoice.received, invoice.accepted, invoice.rejected, invoice.duplicate, invoice.undelivered, invoice.pending, invoice.business_status_changed or invoice.status_changed. If the webhook is limited to one entity, invoiceId must belong to that entity (otherwise 400). Pass invoiceId (from GET /v1/invoices) to build the payload from one of your own invoices instead of sample values. The webhook is sent the test even if it is not subscribed to that event; the response then carries subscribed: false and a note that explains when a real event would not reach the webhook: the event is not listed (for invoice.status_changed, "*" does not count) or the status is not in the webhook's statuses.
For invoice.status_changed you can add "to", the status the sample reports, for example { "webhookId": "<id>", "event": "invoice.status_changed", "to": "WAITING_FOR_YOU" }. It is only valid with that event and an unknown status is a 400. Without to, the sample uses the status of the invoice you pass in invoiceId, or COMPLETED when there is none. The payload carries "test": true.
The response always reports what your endpoint did: delivered (true on a 2xx), status (your endpoint's HTTP status, or null), durationMs, the exact payload sent, and, when delivery failed, error.code (non_2xx_response, redirect_not_followed, destination_not_allowed, timeout or connection_failed) with a message. A failing receiver is still HTTP 200 here, so you can tell it apart from a bad request (400) or an unknown webhook (404).
The same check is available as the test_webhook MCP tool, client.testWebhook() in @clearvo/sdk and clearvo webhooks test <id> in the CLI.
Manage your endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /webhooks | List active endpoints. Query: page (default 1) and limit (default 50, max 100). Returns { webhooks, pagination: { total, page, limit, pages, hasNext, hasPrev } }; each webhook includes its statuses (an empty list when unset). The secret is never returned. |
| POST | /webhooks | Register a new endpoint. Returns the secret once. |
| POST | /webhooks/test | Send a signed test event to one endpoint (see above). Returns what your endpoint answered. |
| DELETE | /webhooks?id=<id> | Deactivate an endpoint. Returns { ok: true, id }, or 404 if the webhook is not found or already inactive. |