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 Settings → Webhooks. getpeppr will send a POST request to your endpoint for each event.
Requirements
- Your endpoint must accept
POSTrequests withapplication/jsonbody - Respond with a
2xxstatus code within 5 seconds - Use HTTPS in production
Event Types
| Event | Description |
|---|---|
invoice.sent | Invoice successfully delivered to recipient's access point |
invoice.accepted | Recipient accepted the invoice |
invoice.refused | Recipient rejected the invoice |
invoice.error | Delivery failed (final state) |
invoice.registered | Cleared by tax authority (e.g., KSA, PT) |
invoice.received | Receipt acknowledged by recipient |
invoice.paid | Payment confirmed by recipient |
legal_entity.registered | Platform sub-tenant reached a verified or active state |
legal_entity.verification_failed | Platform sub-tenant registry verification failed |
legal_entity.awaiting_authz | Platform sub-tenant authorisation email is awaiting customer action |
legal_entity.registration_failed | Platform sub-tenant identity verified but network (SMP) registration failed |
peppol_identifier.verified | A Peppol identifier completed registry verification |
peppol_identifier.verification_failed | A Peppol identifier failed registry verification |
test.ping | Test event sent during endpoint setup |
inbound.invoice.received | An invoice addressed to your Legal Entity was received from the Peppol network (pilot — contact support to enable) |
inbound.creditnote.received | A credit note addressed to your Legal Entity was received from the Peppol network (pilot — contact support to enable) |
* | Wildcard — subscribes to all event types |
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, and filterable with the same event subscriptions.
subTenantIdmaps back to theexternalSubTenantIdyou supplied when creating the customer.legalEntityIdis the getpeppr legal entity id for follow-up API calls.statusis one ofverified,verification_failed,awaiting_authz, oractive.environmenttells you whether the transition happened insandboxorproduction.
sender.externalSubTenantId
and production send gates, see
Platform Sending & Webhooks.
{
"id": "evt_1a2b3c",
"type": "legal_entity.registered",
"data": {
"subTenantId": "customer_8412",
"legalEntityId": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"peppolId": "GB:CRN:12345678",
"status": "active",
"environment": "production",
"occurredAt": "2026-06-01T10:05:00.000Z"
},
"createdAt": "2026-06-01T10:05:00.000Z"
}Inbound Reception (Pilot)
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 arrivedinbound.creditnote.received— a credit note addressed to your Legal Entity arrived
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 are stored but
not embedded: content will be null and
contentOmittedReason will be "size". A retrieval API is planned for
Phase 2.
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 only identifies a single delivery
attempt of one outbox row and does not dedupe duplicates created by internal
redelivery repair (the same document can arrive under different event.id values).
{
"id": "evt_def456ghi789",
"type": "inbound.invoice.received",
"data": {
"receivedDocumentId": "9f1a2b3c-4d5e-6f70-8a9b-0c1d2e3f4a5b",
"legalEntityId": "7c9a1b34-2d5e-4f60-8a1b-9c2d3e4f5a6b",
"externalSubTenantId": "customer_8412",
"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"
}Handler Example
Here's a complete webhook handler using the SDK types. The WebhookEvent
type ensures type safety for all event payloads.
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
switch (event.type) {
case "invoice.sent":
console.log(`Invoice ${event.data.invoiceId} delivered!`);
break;
case "invoice.accepted":
console.log(`Invoice ${event.data.invoiceId} accepted!`);
break;
case "invoice.refused":
console.error(`Invoice ${event.data.invoiceId} refused`);
break;
case "invoice.error":
console.error(`Invoice ${event.data.invoiceId} delivery failed`);
break;
case "invoice.paid":
console.log(`Invoice ${event.data.invoiceId} paid!`);
break;
}
// 3. Always respond quickly — process async if needed
res.status(200).send("ok");
});
// Event types:
// "invoice.sent" — delivered to recipient's access point
// "invoice.accepted" — recipient accepted the invoice
// "invoice.refused" — recipient rejected the invoice
// "invoice.error" — delivery failed (final state)
// "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
// "legal_entity.registered" — sub-tenant reached verified/active
// "legal_entity.verification_failed" — sub-tenant registry check failed
// "legal_entity.awaiting_authz" — sub-tenant authorisation requested
// "test.ping" — test event for setup verificationPayload Format
Every webhook request contains a JSON body with the following structure:
id— unique event ID (identifies one delivery; for inbound events deduplicate ondata.receivedDocumentIdinstead)type— event type stringdata— event-specific payloadcreatedAt— ISO 8601 timestamp
{
"id": "evt_abc123def456",
"type": "invoice.sent",
"data": {
"invoiceId": "12345",
"invoiceNumber": "INV-2026-001",
"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 Settings → 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.
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 signeds— 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:
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.ididentifies a single delivery attempt, not the document. - Respond quickly — return
2xxwithin 5 seconds and process asynchronously; failed deliveries are retried with backoff.