Skip to content

Webhooks

Receive real-time notifications when invoices move through delivery, or when platform sub-tenants change lifecycle state.

Setup

Configure your webhook endpoint in the getpeppr console under Developer → Webhooks. getpeppr will send a POST request to your endpoint for each event.

Requirements

  • Your endpoint must accept POST requests with application/json body
  • Respond with a 2xx status code within 5 seconds
  • Your endpoint URL must use https://, in sandbox and in production — payloads can carry invoice data, so plain http:// URLs are rejected when you save them and never receive a delivery

How many endpoints, and which environment

How many endpoints you can save depends on your plan, per environment: 1 on Sandbox, 3 on Starter, 5 on Pro, 10 on Business, and 5 on a Platform account. An endpoint that receives both environments counts against both.

Each endpoint receives both sandbox and production events unless you scope it. Set Environment to Sandbox only or Production only in the console, and that endpoint receives nothing from the other one — which is how you point a staging server at getpeppr without it receiving your customers' production documents. A new endpoint receives both environments until you choose.

A scoped endpoint also receives nothing when an event carries no environment at the root (older queued events, and status events whose environment is not resolved yet). Leave an endpoint unscoped if you want those. The test.ping you send from the console is not affected: it goes to the endpoint you asked it for, whatever its scope.

Event Types

EventDescription
invoice.sentInvoice successfully delivered to recipient's access point
invoice.acceptedRecipient accepted the invoice
invoice.refusedRecipient rejected the invoice
invoice.errorThe invoice failed (final state): delivery failed, or the recipient's accredited platform rejected it after receiving it (France: Rejetée, 213). Either way, correct it and send again. detail.platformFiscal on the invoice tells which.
invoice.registeredCleared by the sender's tax authority (e.g. KSA, PT; in France, the invoice data reported to the DGFiP — never delivery, which is invoice.sent)
invoice.receivedReceipt acknowledged by recipient
invoice.paidPayment confirmed by recipient
invoice.undeliverableNot deliverable — no receiving capability found for the recipient on the Peppol network. The payload carries status: "no_action" (final state for the send). Also sent when no delivery evidence has appeared after 7 days — that window is our own policy, not a verdict from the network
invoice.delivery_unconfirmedNo delivery evidence yet for a document we accepted. This is not a failure: if delivery is confirmed later, invoice.sent follows normally and supersedes it. We send it so that silence reaches you from us rather than from your own timer
invoice.partially_paidRecipient confirmed a partial payment
invoice.under_queryRecipient raised a question about the invoice — clarification expected before acceptance
invoice.conditionally_acceptedRecipient accepted the invoice subject to conditions
invoice.status_changedGeneric status notification carrying the full per-axis state — opt-in: never matched by the * wildcard, subscribe to it explicitly. See invoice.status_changed
legal_entity.registeredPublic receive discovery verified the exact SML → SMP → Invoice metadata → active AS4 path for the platform sub-tenant
legal_entity.unsupported_schemeNo automatic validator is active for the sub-tenant's identifier scheme; no registry decision was made and no work remains in progress
legal_entity.verification_failedPlatform sub-tenant registry verification failed
legal_entity.awaiting_authzPlatform sub-tenant authorisation email is awaiting customer action
legal_entity.awaiting_releaseProduction: platform sub-tenant authorised, but another Peppol access point still holds the address; registered automatically once released
legal_entity.registration_failedPlatform sub-tenant identity verified but the network registration failed
peppol_identifier.verifiedA Peppol identifier completed registry verification
peppol_identifier.verification_failedA Peppol identifier failed registry verification
test.pingTest event sent during endpoint setup
inbound.invoice.receivedAn invoice addressed to one of your Legal Entities was received from the Peppol network
inbound.creditnote.receivedA credit note addressed to one of your Legal Entities was received from the Peppol network
inbound.document.undeliverableA document addressed to your account was received from the Peppol network but could not be delivered to you. Also sent to endpoints subscribed to the reception event of that document's type
*Wildcard — subscribes to all event types except invoice.status_changed (opt-in only)

invoice.status_changed

invoice.status_changed is a generic status notification dispatched on every provider status update for a sent document, alongside the specific historic event (invoice.accepted, invoice.paid, …). Where the historic events tell you what happened, status_changed carries the full resulting state: the legacy status plus a per-axis breakdown (axes) and, when the jurisdiction provides one, the structured national detail (detail) with the native code preserved (e.g. a French DGFiP lifecycle code).

Opt-in only. A * wildcard subscription never receives invoice.status_changed — you must list it explicitly in the endpoint's events. This guarantees wildcard subscribers get exactly one notification per status update, never two.

Stable event ids. Status events (historic and status_changed) carry a deterministic id derived from the underlying provider occurrence: if the provider redelivers the same status update, you receive the same event id again. Deduplicate by id on your side — deliveries are at-least-once.

axes values are nullable — an axis is null until a status update touches it. detail is omitted when no structured national detail exists for the document. The status field uses the same vocabulary as Document Status.

invoice.status_changed
{
  "id": "evt_9f2c8a41d3b7e650",
  "type": "invoice.status_changed",
  "environment": "production",
  "data": {
    "invoiceId": "8b3f2c1d-4a5e-4f6b-9c8d-7e6f5a4b3c2d",
    "submissionId": "8b3f2c1d-4a5e-4f6b-9c8d-7e6f5a4b3c2d",
    "invoiceNumber": "INV-2026-001",
    "providerDocumentId": "storecove-guid-abc123",
    "status": "delivered",
    "environment": "production",
    "axes": {
      "platformFiscal": "submitted_to_network",
      "delivery": "delivered",
      "businessDisposition": null,
      "settlement": null
    },
    "detail": {
      "delivery": {
        "axis": "delivery",
        "jurisdiction": "*",
        "code": "succeeded",
        "label": "Delivered",
        "codeSystem": "peppol-transport",
        "codeVersion": "1"
      }
    }
  },
  "createdAt": "2026-07-14T10:05:00.000Z"
}

Platform Lifecycle Webhooks

Platform accounts receive the legal_entity.* events above on the same endpoint delivery channel as invoice webhooks: signed with Getpeppr-Signature, retried automatically once the durable outbox row exists, and filterable with the same event subscriptions.

legal_entity.registered is receive-readiness proof: it fires only after the public SML → SMP → Invoice metadata → active AS4 path succeeds. Identity verification and Storecove registration alone do not emit it. Its status is active, or no_registry for the sandbox registryless test scheme. Outbound sending remains independent and keeps its existing gates.

A customer already registered before public discovery proof existed may receive one new legal_entity.registered after its first successful cross-Access-Point check. This is a corrected readiness occurrence, not a retry of the older provider-local event, so it has a different event.id. Keep lifecycle effects idempotent by legalEntityId and use GET /v1/legal-entities/:id as the current source of truth.
  • subTenantId maps back to the externalSubTenantId you supplied when creating the customer.
  • legalEntityId is the getpeppr legal entity id for follow-up API calls.
  • status is one of verified, no_registry, unsupported_scheme, verification_failed, registration_failed, awaiting_authz, awaiting_release, or active.
  • reason is present on failures. For registration_failed it is already_registered, invalid_format, or provider_error, matching the current Legal Entity's registrationDetail.reason.
  • heldBy is present on legal_entity.awaiting_release when known: the SMP host of the access point that still holds the address.
  • environment tells you whether the transition happened in sandbox or production.
For the full multi-tenant sending flow, including sender.externalSubTenantId and production send gates, see Platform Sending & Webhooks.
legal_entity.registered
{
  "id": "evt_1a2b3c",
  "type": "legal_entity.registered",
  "environment": "production",
  "data": {
    "subTenantId": "customer_be_001",
    "legalEntityId": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
    "peppolId": "0208:YOUR_10_DIGIT_KBO",
    "status": "active",
    "environment": "production",
    "occurredAt": "2026-06-01T10:05:00.000Z"
  },
  "createdAt": "2026-06-01T10:05:00.000Z"
}

Inbound Reception

Every Legal Entity receives from the day it is registered, in sandbox and in production, with nothing to enable. Received documents count toward your usage exactly like sent ones. On a platform account, each customer Legal Entity receives its own documents as long as it stays registered.

When a supplier on the Peppol network sends an invoice or credit note to one of your Legal Entities, getpeppr stores the document and dispatches one of these events:

  • inbound.invoice.received — an invoice addressed to your Legal Entity arrived
  • inbound.creditnote.received — a credit note addressed to your Legal Entity arrived
Not to be confused with invoice.received — that existing outbound event means “a document you sent was acknowledged by the recipient’s access point”. The inbound.* events mean a document was sent to you by a third party.

Document embed

The UBL XML content is embedded in the webhook payload as a base64-encoded string (field data.document.content). Documents larger than 512 KB, up to the 10 MiB storage limit, are stored but not embedded: content will be null and contentOmittedReason will be "size". Fetch those — and any document you did not persist — with GET /v1/received-documents/{id}/as/xml, which returns the original UBL byte for byte. Use data.receivedDocumentId as the id.

We keep the original for 90 days after receipt, or until a Platform contract's exit window closes if that comes first. After that the download answers 410 Gone (received_documents.xml_expired); the document's details stay listed. getpeppr is not an archive: store the original on your side when it reaches you.

Documents we cannot deliver

When a document arrives for your account but getpeppr cannot deliver it to you, you receive inbound.document.undeliverable instead of inbound.*.received. There is no received document behind it: no receivedDocumentId, and nothing to fetch from /v1/received-documents. It does not count toward your usage.

Result codeWebhook reasonWhat it means and what to do
inbound.document_too_largetoo_largeA document sent to your account is larger than getpeppr can receive (10 MiB). Ask the sender to send it again under that size, for example with smaller attachments.

In the dashboard, the document appears on the Invoices page with the status Undeliverable, with its own detail page, and as a failed entry in Activity. Both show the result code and the sentence above. The Invoices page shows the 500 most recent of them; past that, it says how many more there are.

No subscription needed. An endpoint subscribed to the reception event of the document's type receives this event too: inbound.invoice.received for an invoice, inbound.creditnote.received for a credit note, either one when the type could not be read.
FieldMeaning
undeliverableDocumentIdThe key to deduplicate on
providerDocumentIdThe reference to give support
reasontoo_large: over the storage limit, or in an access-point response too large to read. legal_entity_unresolved: the access point named no Legal Entity active on your account, for example one you archived just before the document arrived
storageLimitBytesThe storage limit, 10 MiB
sizeBytesThe exact size when it could be measured, otherwise null
legalEntityId, externalSubTenantIdFilled when it was read before the notice was prepared and the document provably belongs to that Legal Entity, otherwise null
documentType, invoiceNumber, senderFilled when they were read before the notice was prepared, otherwise null. sender is always an object; its peppolId and name follow that rule
One exception. If a Legal Entity is deleted while one of its documents is being delivered, you can receive inbound.*.received for that document, which may already count, followed by this event naming no Legal Entity.
inbound.document.undeliverable
{
  "id": "evt_0a1b2c3d4e5f6071",
  "type": "inbound.document.undeliverable",
  "environment": "production",
  "data": {
    "undeliverableDocumentId": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a99",
    "providerDocumentId": "storecove-guid-def456",
    "reason": "too_large",
    "environment": "production",
    "legalEntityId": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
    "externalSubTenantId": "customer_be_001",
    "documentType": "invoice",
    "sender": {
      "peppolId": "0208:0123456789",
      "name": "Supplier SA"
    },
    "invoiceNumber": "INV-456",
    "receivedAt": "2026-09-15T08:00:00.000Z",
    "sizeBytes": null,
    "storageLimitBytes": 10485760
  },
  "createdAt": "2026-09-15T08:00:01.000Z"
}

Deduplication

Delivery is at-least-once. Deduplicate on data.receivedDocumentId — it is the stable idempotency key for inbound events: one received document, one id, however many times it is delivered. event.id is stable per received document too, but it lives in our delivery outbox, whose delivered and failed rows are purged after 90 days — receivedDocumentId is the key that survives that purge.

inbound.invoice.received
{
  "id": "evt_def456ghi789",
  "type": "inbound.invoice.received",
  "environment": "sandbox",
  "data": {
    "receivedDocumentId": "9f1a2b3c-4d5e-6f70-8a9b-0c1d2e3f4a5b",
    "legalEntityId": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
    "externalSubTenantId": "customer_be_001",
    "documentType": "invoice",
    "sender": {
      "peppolId": "0208:0123456789",
      "name": "Supplier SA"
    },
    "invoiceNumber": "INV-123",
    "receivedAt": "2026-06-11T08:00:00.000Z",
    "providerDocumentId": "storecove-guid-abc123",
    "document": {
      "format": "ubl",
      "encoding": "base64",
      "content": "<base64 UBL XML>",
      "contentOmittedReason": null,
      "sizeBytes": 12345
    }
  },
  "createdAt": "2026-06-11T08:00:01.000Z"
}

Where sender comes from. Both fields are read from the supplier party the document names for itself — cac:AccountingSupplierParty/cac:Party, its cbc:EndpointID and that element's schemeID — and from nowhere else. It is what the document claims, which for a conformant Billing message is the same identifier the envelope was sent under (rule BR-SBDH-2), but we publish the document's claim, not the transport identity.

We never guess it. If the document does not state a supplier endpoint unambiguously, both fields are null rather than filled from what our provider knows about the sender — an identifier you reconcile a supplier on is worth nothing if it might be a guess. A conformant Peppol document always states it (cbc:EndpointID is mandatory, with a mandatory schemeID), so in practice null means a document that did not come through the network or that we could not read. On inbound.document.undeliverable with reason: "too_large" the document is never parsed at all, so sender there is always null; use providerDocumentId to trace it.

The scheme is the numeric EAS code when it is in the CEF EAS code list, and the scheme as declared otherwise. to.peppolId accepts both that code and the scheme's symbolic alias, so compare the two canonically rather than as raw strings.

Handler Example

Here's a complete webhook handler using the SDK types. WebhookEvent types the envelope (id, type, createdAt); data is unknown, so narrow it per event.type as the example does.

import { webhooks } from "@getpeppr/sdk";
import type { WebhookEvent } from "@getpeppr/sdk";

// In your Express / Hono / Fastify route handler
app.post("/webhooks/peppol", async (req, res) => {
  // 1. Verify the signature (critical for security)
  let event: WebhookEvent;
  try {
    event = await webhooks.constructEvent(
      req.body,              // raw body string (NOT parsed JSON)
      req.headers["getpeppr-signature"] as string,
      "whsec_your_secret"    // from dashboard
    );
  } catch (err) {
    console.error("Signature verification failed:", err);
    return res.status(400).send("Invalid signature");
  }

  // 2. Handle the event. `event.data` is `unknown` on the envelope type —
  //    narrow it per event type before reading fields.
  const { invoiceId } = event.data as { invoiceId: string };
  switch (event.type) {
    case "invoice.sent":
      console.log(`Invoice ${invoiceId} delivered!`);
      break;

    case "invoice.accepted":
      console.log(`Invoice ${invoiceId} accepted!`);
      break;

    case "invoice.refused":
      console.error(`Invoice ${invoiceId} refused`);
      break;

    case "invoice.error":
      // Undelivered, or rejected by the recipient's platform: fix and resend
      console.error(`Invoice ${invoiceId} failed`);
      break;

    case "invoice.paid":
      console.log(`Invoice ${invoiceId} paid!`);
      break;
  }

  // 3. Always respond quickly — process async if needed
  res.status(200).send("ok");
});

// Event types (excerpt — the table above lists all 24):
// "invoice.sent"       — delivered to recipient's access point
// "invoice.accepted"   — recipient accepted the invoice
// "invoice.refused"    — recipient rejected the invoice
// "invoice.error"      — failed (final state): undelivered, or rejected by the recipient's platform
// "invoice.registered" — cleared by tax authority
// "invoice.received"   — receipt acknowledged by recipient
// "invoice.paid"       — payment confirmed by recipient
// "inbound.invoice.received"    — an invoice addressed to your Legal Entity arrived
// "inbound.creditnote.received" — a credit note addressed to your Legal Entity arrived
// "inbound.document.undeliverable" — a document arrived for you but could not be delivered
// "legal_entity.registered"          — public SML → SMP → Invoice metadata → active AS4 discovery succeeded
// "legal_entity.unsupported_scheme"  — no automatic validator is active
// "legal_entity.verification_failed" — sub-tenant registry check failed
// "legal_entity.awaiting_authz"      — sub-tenant authorisation requested
// "legal_entity.awaiting_release"    — authorised; another access point still holds the address
// "legal_entity.registration_failed" — identity verified, network registration failed
// "test.ping"          — test event for setup verification

Payload Format

Every webhook request contains a JSON body with the following structure:

  • id — event ID, the same for every endpoint and every retry of that event (for inbound events deduplicate on data.receivedDocumentId instead)
  • type — event type string
  • environment — "sandbox" or "production": the business environment of the resource this event is about. Read it after verifying the signature, then call the API with the matching key (sk_sandbox_* / sk_live_*). The field is optional by contract, for three reasons: events emitted before it existed are still replayed verbatim, test.ping — a synthetic event tied to no resource — deliberately carries none, and a status event whose submission environment is not yet resolved transiently carries none either (the field appears on the next event once resolution lands). Absence never means “production by default” — treat “no field” as “not yet known”. When present, the value is exact, never guessed.
  • data — event-specific payload
  • createdAt — ISO 8601 timestamp
Picking the wrong key looks like the document does not exist. A sk_live_* key reading a sandbox document — or the reverse — gets a 404, not a permission error: each environment only sees its own documents. Use the root environment field to route to the right key. Some event families also carry an environment inside data; it is the same value, kept for compatibility — the root field is the documented one.
Webhook Payload
{
  "id": "evt_abc123def456",
  "type": "invoice.sent",
  "environment": "sandbox",
  "data": {
    "invoiceId": "8b3f2c1d-4a5e-4f6b-9c8d-7e6f5a4b3c2d",
    "submissionId": "8b3f2c1d-4a5e-4f6b-9c8d-7e6f5a4b3c2d",
    "invoiceNumber": "INV-2026-001",
    "providerDocumentId": "storecove-guid-abc123",
    "status": "delivered",
    "environment": "sandbox"
  },
  "createdAt": "2026-03-01T10:05:00.000Z"
}

Security

Every webhook request carries a Getpeppr-Signature header. Verify it on your endpoint to prove the request came from getpeppr and was not tampered with in transit.

The signing secret

Each endpoint you register gets its own signing secret (prefixed whsec_), shown once when you create it under Developer → Webhooks. Copy it then — it is masked on every later view, and secrets are not rotatable: to replace one, delete the endpoint and create a new one.

There is no separate “sandbox” secret. The same per-endpoint whsec_ signs every event to that endpoint in both sandbox and production, so your verification code keeps working unchanged when you later move a participant to production.

Signature format

The header value has the shape t={unix_seconds},s={hmac_sha256_hex}, for example t=1751458504,s=d9b60cb5…:

  • t — Unix timestamp (in seconds) at which the event was signed
  • s — the signature: HMAC-SHA256(secret, "{t}.{rawBody}"), hex-encoded

The signed message is the timestamp, a literal ., then the raw request body — exactly the bytes you received. Recompute the HMAC with your secret and compare it to s.

Verify with the SDK (recommended)

await webhooks.constructEvent(rawBody, signatureHeader, secret) from @getpeppr/sdk does everything in one call — header parsing, the HMAC, a constant-time comparison, and a 5-minute replay-tolerance check — and throws if any step fails. See the Handler Example above.

Verify without the SDK

Any language with an HMAC library can verify the signature. In Node.js:

verify.ts
import crypto from "node:crypto";

/** Verify an incoming getpeppr webhook. Throws if the signature is invalid. */
function verifyGetpepprSignature(
  rawBody: string,          // the exact raw request body (a string, NOT parsed JSON)
  signatureHeader: string,  // the Getpeppr-Signature header value
  secret: string,           // your endpoint's whsec_ signing secret
  toleranceSeconds = 300,
): unknown {
  const match = signatureHeader.match(/t=([^,]+),s=(.+)/);
  if (!match) throw new Error("Malformed Getpeppr-Signature header");
  const [, timestamp, received] = match;

  // Replay protection: reject events older than the tolerance window.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(Number(timestamp)) || age > toleranceSeconds) {
    throw new Error("Timestamp outside tolerance");
  }

  // Recompute HMAC-SHA256 over "{timestamp}.{rawBody}" and compare in constant time.
  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestamp + "." + rawBody)
    .digest("hex");
  const ok =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!ok) throw new Error("Invalid signature");

  return JSON.parse(rawBody); // safe to trust and parse now
}

Common pitfalls

  • Hash the raw body — verify against the exact bytes received, before any JSON parsing. Re-serialising the parsed object (key order, whitespace) changes the HMAC and makes a valid signature look invalid. This is the most common cause of a false “invalid signature”.
  • Keep your clock in sync — verification rejects events older than 5 minutes as replay protection, so a drifting server clock fails legitimate webhooks. Run NTP.
  • Deduplicate — delivery is at-least-once. Use the stable resource key in data (for inbound events, data.receivedDocumentId); event.id identifies the event, and the log that holds it is purged after 90 days.
  • Respond quickly — return 2xx within 5 seconds and process asynchronously; failed deliveries are retried with backoff.
Never trust an unverified payload. Without signature verification, an attacker who learns your endpoint URL could POST fake events to it.