New France e-invoicing mandate goes live September 2026 — our implementation is ready. See all mandates →
Guides

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.

Guides

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
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 fieldTypeDescription
urlstringDestination URL. Must be https://. Required
eventsstring[]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
statusesstring[]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
entityIdstringOnly 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.

JSON Response (201)
{
  "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:

EventFires when
invoice.status_changedAn 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.acceptedThe authority cleared the invoice.
invoice.rejectedThe authority — or, for France, the customer — rejected the invoice. The payload carries rejectedBy (authority or customer) and a structured rejectionDetail.
invoice.receivedA 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_receivedBrazil 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.duplicateThe submission matched an existing invoice.
invoice.pendingThe authority is still processing — no terminal outcome yet.
invoice.undeliveredItaly: the recipient was unreachable and the 10-day retry period started.
invoice.business_status_changedA 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_errorsSpain 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_errorsSpain SII: sent once per invoice when it first needs a correction. Carries recommendedAction (amend_report or cancel_report).
invoice.amend_dueSpain SII: a later reminder that an invoice accepted with errors still needs its amendment. Carries recommendedAction.
invoice.amendedSpain SII: an A1 amendment was accepted by AEAT.
invoice.cancelledSpain SII: a Baja (cancellation) was accepted by AEAT; the invoice moves to CANCELLED.
invoice.report_due_soonA reportable transaction is approaching its reporting deadline.
invoice.report_overdueA reportable transaction has passed its reporting deadline without being reported.
invoice.reportedPortugal: AT accepted the communication of an issued invoice's data (a separate obligation from invoice issuance).
invoice.report_rejectedPortugal: AT rejected that communication.
reporting_batch.ready_for_reviewSpain SII: a review-mode batch is frozen and waiting for your confirmation.
reporting_batch.deadline_approachingSpain SII: 2 and 1 Spanish business days before the batch's report-by date.
reporting_batch.deadline_passedSpain SII: the first business day after the report-by date, or when an automatic submission gave up past its deadline.
reporting_batch.overdue_reminderSpain 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_decisionNo compliance-mandate rule matched the transaction — held on our side, not a fault on yours. Carries holdReason and actionOwner.
product.classification_changedA product's suggested tax category or status changed (for example after a bulk reclassification). Carries productId, externalProductId, oldTaxCategorySlug, newTaxCategorySlug, newTaxProductStatus and classificationSource.
ap.decisionAn 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_neededA 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
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).

JSON Payload
{
  "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..."
}
FieldDescription
id, submissionIdThe invoice id. Both carry the same value; submissionId is kept so the field matches the other events.
referenceId, country, documentTypeAs on the other invoice events. referenceId is null when the document has none.
status, previousStatusThe 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.
statusClassWho the document is now waiting for: IN_FLIGHT, WAIT_MERCHANT, WAIT_PLATFORM, DONE_OK, DONE_REJECTED or DONE_NA.
terminaltrue when no further status change will happen.
reasoncode 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.
nextStepactor (merchant, platform, authority or system), message and, when you can fix it yourself, fixUrl. null when nothing is outstanding.
at, updatedAtWhen the change happened (ISO 8601). Both carry the same value.
entityId, organisationIdThe entity. organisationId is present on partner and organisation webhooks.
testtrue, 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. seq increases with every status change of one invoice. Deliveries are retried and can arrive out of order: for each invoice, keep the highest seq you 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, and reason says exactly what is needed.
  • Tell me the final answer: ["COMPLETED", "REJECTED"].
  • Everything: leave statuses out.

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.

HTTP
x-taxually-signature: sha256=5f3d...
x-taxually-timestamp: 1758067200
x-taxually-webhook-id: a1b2c3d4-...
x-taxually-delivery-id: d4c3b2a1-...
JavaScript
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

MethodPathPurpose
GET/webhooksList 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/webhooksRegister a new endpoint. Returns the secret once.
POST/webhooks/testSend 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.
✓
Deliveries are attempted up to 10 times against a failing endpoint. If every attempt fails, we email your entity's users so you don't need to build your own dead-letter monitoring.