Webhooks
Webhooks deliver events from the Peppol pipeline (sent, delivered, failed, received) to your HTTPS endpoint. Every delivery is HMAC-signed and retried with exponential backoff until you acknowledge it with HTTP 2xx.
Registering an endpoint
You register an endpoint in the portal under Webhooks, or via POST /v1/partner/webhooks. On creation you get the signing secret only once. Store it. A partner can have up to 20 endpoints; the URL must be https://. By default an endpoint receives events for allof the partner's organizations; pass an optional orgId on creation to restrict it to a single organization.
Event types
| Event | When |
|---|---|
peppol.document.sent | The document was accepted by the network (AS4 receipt). |
peppol.document.delivered | The document was delivered to the recipient (MLS/delivery receipt). |
peppol.document.failed | Sending failed (final). |
peppol.document.received | An inbound document arrived for you over Peppol. |
participant.activated | The organization is registered in the SMP and receives documents (after the FS mandate is confirmed or after POST /v1/agent/peppol/enroll). |
participant.failed | Participant registration failed (invalid FS token, SMP error, collision with another company, deactivated organization). |
participant.deactivated | The organization was de-registered from the SMP (POST /v1/agent/organizations/{id}/peppol/deactivate). |
usage.limit_exceeded | The organization exceeded its included monthly transaction volume. |
participant.* payloads carry data.participantId (0245:DIČ, 9915:DIČ in the sandbox), data.scheme, data.mode (live / test), data.source (partner_claim / api_enroll / api_deactivate) and a timestamp activatedAt / failedAt / deactivatedAt; participant.failed additionally carries data.code and data.message.
peppol.document.status_changed is in the catalog but is not emitted yet. Transport status changes are covered by the sent / delivered / failed events above.Payload format
The body is JSON with an event / timestamp (ISO 8601) / data envelope. The data.orgId field is always present and identifies the client organization. Test sends carry data.mode = "test".
{
"event": "peppol.document.delivered",
"timestamp": "2026-06-12T19:48:01.000Z",
"data": {
"invoiceId": "b1f0…",
"invoiceNumber": "2026001",
"documentType": "invoice",
"mode": "test",
"state": "DELIVERED",
"messageId": "…",
"transactionId": "…",
"orgId": "0a2c…"
}
}| Event | Fields in data (besides orgId) |
|---|---|
sent | invoiceId, invoiceNumber, documentType, mode, state, messageId, transactionId |
delivered | invoiceId, invoiceNumber, documentType, mode, state, messageId, transactionId |
failed | invoiceId, invoiceNumber, documentType, error |
received | senderName, senderParticipantId, documentNumber, documentType, total, currency |
Headers
| Header | Description |
|---|---|
X-Webhook-Signature | The signature in t=<unix>,v1=<hex> format (Stripe-style). |
X-Webhook-Event | The event name. |
X-Webhook-Id | A delivery UUID (idempotency key). |
X-Webhook-Timestamp | The signature's Unix time (seconds). |
Verifying the signature
The string <timestamp>.<raw body> is signed with HMAC-SHA256 and your secret; the result is lowercase hex in the v1= segment. During secret rotation the header may carry multiple v1= segments (old + new): a match on either is sufficient. Compare in constant time and reject a timestamp that's too old (replay protection).
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
// header may carry several v1=… segments during secret rotation
return header
.split(",")
.filter((kv) => kv.startsWith("v1="))
.some((kv) => {
const got = kv.slice(3);
return (
got.length === expected.length &&
timingSafeEqual(Buffer.from(got), Buffer.from(expected))
);
});
}Delivery and retries
Delivery is durable (a queue + persisted history). Your endpoint should respond with HTTP 2xx within a few seconds; do the processing asynchronously. On failure (non-2xx or timeout), delivery is retried 5× with exponential backoff (5s, 10s, 20s, 40s, 80s): 6 attempts total.
| Mechanism | Behavior |
|---|---|
| Retries | Initial attempt + 5 retries (5/10/20/40/80 s). |
| Idempotency | Deduplicate by X-Webhook-Id: a replay sends the same ID. |
| Auto-disable | After 15 consecutive failed deliveries the endpoint is deactivated. |
| Secret rotation | The old secret verifies for another 24 h (dual v1= signature). |
Testing
In the portal you can send a test event to an endpoint, view the delivery history (payload + response), and replay any past delivery. In the sandbox (test key), a real peppol.document.delivered with data.mode = "test" fires after delivery between registered test participants.