Working with Webhooks
Set up webhooks to receive real-time notifications for Peppol document events like incoming invoices, delivery status updates, and more.
This guide explains how to set up and use webhooks to receive real-time notifications about Peppol document events using the Recommand API.
Need more control than a single webhook?
Webhooks are part of Recommand's broader rule system. If you want to filter by event details, send emails, combine multiple actions, or manage delivery retries from one place, see Working with Rules.
Overview
Webhooks allow your systems to receive instant notifications when new documents are received or other events occur in the Peppol network. Instead of constantly polling the API for updates, you can have updates pushed to your system as they happen.
Prerequisites
- A Recommand account with API access
- Your API key and secret
- Your team ID
- A publicly accessible URL endpoint on your server to receive webhook events
Setting Up a Webhook Endpoint
First, you need to create an endpoint on your server to receive the webhook events.
Node.js Example
// Using Express.js
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.json());
app.post("/peppol-webhook", (req, res) => {
const event = req.body;
// Process the event
console.log("Received webhook event:", event);
// Process new document
const docId = event.documentId;
// TODO: Fetch the document, process it, etc.
// Always respond with a 200 OK to acknowledge receipt
res.status(200).send("Event received");
});
app.listen(3000, () => {
console.log("Webhook server listening on port 3000");
});PHP Example
<?php
// webhook-receiver.php
$payload = file_get_contents('php://input');
$event = json_decode($payload, true);
// Log the event
file_put_contents('webhook_log.txt', date('Y-m-d H:i:s') . ' - ' . $payload . PHP_EOL, FILE_APPEND);
// Process new document
$docId = $event['documentId'];
// TODO: Fetch the document, process it, etc.
// Always respond with a 200 OK to acknowledge receipt
http_response_code(200);
echo 'Event received';Registering a Webhook
Once your endpoint is ready, register it with the Recommand API using the create webhook endpoint. Webhooks can also be created and managed from the dashboard.
async function createWebhook(webhookData) {
const response = await fetch(`https://app.recommand.eu/api/v1/webhooks`, {
method: "POST",
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
"Content-Type": "application/json",
},
body: JSON.stringify(webhookData),
});
return response.json();
}
// Example usage
const webhookResult = await createWebhook({
url: "https://your-domain.com/peppol-webhook",
companyId: null, // null means all companies, or specify a company ID
secret: "your_webhook_signing_secret",
});
if (webhookResult.success) {
console.log(`Webhook created with ID: ${webhookResult.webhook.id}`);
} else {
console.error("Failed to create webhook:", webhookResult.errors);
}The same request with curl
curl -X POST https://app.recommand.eu/api/v1/webhooks \
-u key_xxx:secret_xxx \
-H "Content-Type: application/json" \
-d @create-webhook.json
{
"url": "https://your-app.example/webhooks/recommand",
"companyId": null,
"secret": "your_webhook_signing_secret"
}
Webhook Fields
| Field | Description | Required | Example |
|---|---|---|---|
url | Your webhook endpoint URL | Yes | "https://your-domain.com/peppol-webhook" |
companyId | Specific company to monitor (null for all) | No | "c_123" or null |
secret | Secret used to sign deliveries with HMAC SHA-256 | No | "your_webhook_signing_secret" |
Verifying Webhook Signatures
When you configure a secret, every delivery includes an X-Signature header in this format:
X-Signature: sha256=<hex digest>The digest is an HMAC SHA-256 signature of the exact raw request body. Verify it before parsing or processing the event. Use a constant-time comparison to avoid timing attacks.
import crypto from "node:crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.RECOMMAND_WEBHOOK_SECRET;
app.post(
"/peppol-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const received = req.get("X-Signature");
const expected = `sha256=${crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body)
.digest("hex")}`;
const receivedBuffer = Buffer.from(received ?? "");
const expectedBuffer = Buffer.from(expected);
const valid =
receivedBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(receivedBuffer, expectedBuffer);
if (!valid) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Process the verified event.
return res.status(200).send("Event received");
}
);Keep the raw body unchanged
Parsing the JSON and serializing it again can change whitespace or property ordering and make a valid signature fail. Configure your framework to retain the original request bytes.
Listing Webhooks
To retrieve all webhooks associated with your team using the list webhooks endpoint:
async function listWebhooks() {
const response = await fetch(`https://app.recommand.eu/api/v1/webhooks`, {
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
});
const result = await response.json();
return result.webhooks;
}
// Example usage
const webhooks = await listWebhooks();
webhooks.forEach((webhook) => {
console.log(`Webhook ID: ${webhook.id}, URL: ${webhook.url}`);
});Updating a Webhook
To update an existing webhook using the update webhook endpoint:
async function updateWebhook(webhookId, updatedData) {
const response = await fetch(
`https://app.recommand.eu/api/v1/webhooks/${webhookId}`,
{
method: "PUT",
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
"Content-Type": "application/json",
},
body: JSON.stringify(updatedData),
}
);
return response.json();
}
// Example usage
const updateResult = await updateWebhook("webhook_id", {
url: "https://your-domain.com/updated-webhook-endpoint",
companyId: "c_123", // Change to monitor a specific company
secret: "your_new_webhook_signing_secret",
});
if (updateResult.success) {
console.log("Webhook updated successfully");
} else {
console.error("Failed to update webhook:", updateResult.errors);
}When updating a webhook, omit secret to keep the current signing secret. Set it to a new non-empty string to rotate the secret, or to null to stop signing deliveries.
Deleting a Webhook
To delete a webhook using the delete webhook endpoint:
async function deleteWebhook(webhookId) {
const response = await fetch(
`https://app.recommand.eu/api/v1/webhooks/${webhookId}`,
{
method: "DELETE",
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
}
);
return response.json();
}
// Example usage
const deleteResult = await deleteWebhook("webhook_id");
if (deleteResult.success) {
console.log("Webhook deleted successfully");
} else {
console.error("Failed to delete webhook:", deleteResult.errors);
}Webhook Event Types
When a document is received or other events occur, your webhook endpoint will receive a POST request with a JSON payload containing the event details.
Document Received Event
For a complete guide on processing incoming documents (including fetching details, accessing parsed content, and polling as an alternative to webhooks), see the Receiving Documents guide.
{
"eventType": "document.received",
"documentId": "doc_xxx",
"teamId": "team_xxx",
"companyId": "c_xxx"
}Upon receiving this event, you would typically:
- Check the
eventTypeto ensure it is a document received event - Retrieve the full document using the
documentIdvia the get document endpoint - Process the document in your system
- Mark the document as read via the mark as read endpoint
// Example of handling a received document event
app.post("/peppol-webhook", async (req, res) => {
const event = req.body;
if (event.eventType === "document.received") {
try {
// 1. Retrieve the document
const document = await fetchDocument(event.documentId);
// 2. Process the document in your system
await processDocumentInYourSystem(document);
// 3. Mark the document as read
await markDocumentAsRead(event.documentId);
console.log(`Successfully processed document ${event.documentId}`);
} catch (error) {
console.error(`Error processing document ${event.documentId}:`, error);
}
}
// Always acknowledge receipt
res.status(200).send("Event received");
});
// Fetch document function
async function fetchDocument(documentId) {
const response = await fetch(
`https://app.recommand.eu/api/v1/documents/${documentId}`,
{
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
}
);
const result = await response.json();
if (!result.success) {
throw new Error(
`Failed to fetch document: ${JSON.stringify(result.errors)}`
);
}
return result.document;
}
// Mark document as read
async function markDocumentAsRead(documentId) {
const response = await fetch(
`https://app.recommand.eu/api/v1/documents/${documentId}/mark-as-read`,
{
method: "POST",
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
"Content-Type": "application/json",
},
body: JSON.stringify({ read: true }),
}
);
const result = await response.json();
if (!result.success) {
throw new Error(
`Failed to mark document as read: ${JSON.stringify(result.errors)}`
);
}
return result;
}Document Sent Event
Fired when a company sends a document, whether it went out over the Peppol network or by email. Use it to record in your own system that the document left the platform, without polling the documents endpoint after every send.
{
"eventType": "document.sent",
"documentId": "doc_xxx",
"teamId": "team_xxx",
"companyId": "c_xxx",
"deliveryStatus": "pending"
}This event confirms acceptance for sending. It also includes deliveryStatus, which can still be pending; a later failure is reported separately. Older queued events may have deliveryStatus: null.
Document Delivery Status Changed Event
Fired when an individual delivery is confirmed or fails, or when a deferred email fallback creates a new attempt. Configure a Delivery status changed rule under Webhooks and rules to send this event to your endpoint.
{
"eventType": "document.delivery_status_changed",
"documentId": "doc_xxx",
"teamId": "team_xxx",
"companyId": "c_xxx",
"deliveryId": "dlv_xxx",
"channel": "peppol",
"address": "0208:987654321",
"status": "failed",
"previousStatus": "pending",
"failure": {
"category": "transport",
"message": "Delivery could not be completed",
"providerCode": null
}
}channel is peppol or email. Statuses are pending, delivered and failed; previousStatus is null for a newly created deferred fallback attempt. failure is null when no failure applies. Failure categories are recipient_not_found, document_not_supported, validation, transport, recipient_rejected, duplicate and other.
A document can have several deliveries. Fetch its deliveries and deliveryStatus to see the combined outcome; one failed channel does not imply every channel failed. Handle webhook retries idempotently. See Tracking Delivery for status semantics and fallback behaviour.
Report Status Changed Event
Fired when a French e-reporting report's reportingStatus or outcomeCode
changes. A report can become filed before its final outcome arrives. Further
events can therefore have the same reporting status and a different outcome.
{
"eventType": "document.reporting_status_changed",
"documentId": "doc_xxx",
"teamId": "team_xxx",
"companyId": "c_xxx",
"reportingStatus": "filed",
"previousReportingStatus": "accepted",
"periodEnd": "2026-09-30",
"submissionId": "sub_xxx",
"outcomeCode": null,
"previousOutcomeCode": null,
"final": false
}reportingStatusandpreviousReportingStatus: one ofaccepted: on file, inside its reporting periodpending_rectificative: arrived after the period was filed, so a corrective filing will carry itfiled: reported to the tax administrationfiled_rectificative: reported by a corrective filingsuperseded: replaced by a correction, or cancelledrejected: refused by the tax administration;outcomeCodesays why
periodEnd: the last day of the reporting period the report belongs tosubmissionId: the period filing the report was carried onoutcomeCode: the tax administration's outcome codepreviousOutcomeCode: the outcome before this change, ornullwhen none was knownfinal: true forfiledorfiled_rectificativewith outcome300, or forsupersededorrejected
periodEnd, submissionId and outcomeCode are null until they are known,
so an early status change carries less detail than a later one.
A filed report normally moves from an empty outcome through 500 to 300.
For an outcome-only change, previousReportingStatus equals reportingStatus.
For example, the final event can carry reportingStatus: "filed",
previousReportingStatus: "filed", previousOutcomeCode: "500",
outcomeCode: "300" and final: true.
For successful completion, require a filed status and outcome 300.
final: true alone also covers rejection and cancellation or replacement.
Route outcome 501 to support with the report reference and period. Handle
webhook retries idempotently and avoid repeating completion actions when several
events have status filed.
For what these statuses mean in the French reporting flow, see the France getting started guide.
Company Verification Event
Fired when a company's verification is completed, either successfully or rejected. Use this event to update your system's record of a company's verified status without polling.
For a full walkthrough of the verification process, see the Company Verification guide.
{
"eventType": "company.verification",
"companyId": "c_xxx",
"teamId": "team_xxx",
"status": "verified"
}status: One of"verified","rejected", or"error""verified": identity check passed and the company is active on the Peppol network"rejected": identity check failed"error": identity check completed, but a subsequent Peppol network operation failed; the company is not yet active
errorMessage: Only present whenstatusis"error". Contains a description of what went wrong.
if (event.eventType === "company.verification") {
if (event.status === "verified") {
console.log(`Company ${event.companyId} is now verified.`);
// e.g. update your local database, enable document sending for this company
} else if (event.status === "rejected") {
console.log(`Company ${event.companyId} verification was rejected.`);
// e.g. notify your team, trigger a new verification attempt
} else if (event.status === "error") {
console.error(`Company ${event.companyId} verification error: ${event.errorMessage}`);
// e.g. alert your team — contact support@recommand.eu for assistance
}
}Label Events
Labels can be attached to or removed from documents manually or via automatic propagation from matched suppliers. These actions trigger label events you can use for routing and integrations (e.g., forward documents with label "ERP" or "Accounting" to specific systems).
For end-to-end usage of suppliers together with labels and label propagation, see the Suppliers and Labels guide.
{
"eventType": "document.label.assigned",
"documentId": "doc_xxx",
"teamId": "team_xxx",
"companyId": "c_xxx",
"labelId": "lab_yyy"
}{
"eventType": "document.label.unassigned",
"documentId": "doc_xxx",
"teamId": "team_xxx",
"companyId": "c_xxx",
"labelId": "lab_yyy"
}When handling these events, fetch the document and inspect its labels to decide how to route it:
if (
event.eventType === "document.label.assigned" ||
event.eventType === "document.label.unassigned"
) {
const doc = await fetchDocument(event.documentId);
const names = new Set((doc.labels || []).map((l) => l.name));
if (names.has("ERP")) {
await forwardToERP(doc);
} else if (names.has("Accounting")) {
await forwardToAccounting(doc);
}
}Downloading Document Packages
When processing documents received through webhooks, you may want to download the complete document package, including XML data and any attachments using the download package endpoint. This is particularly useful when debugging and testing. Document packages can also be downloaded manually from the dashboard.
// Download document package as a zip file
async function downloadDocumentPackage(documentId) {
const response = await fetch(
`https://app.recommand.eu/api/v1/documents/${documentId}/download-package`,
{
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
}
);
if (!response.ok) {
throw new Error(
`Failed to download document package: ${response.statusText}`
);
}
// Handle the zip file content
const packageData = await response.arrayBuffer();
// Example: Save the package to a file (Node.js)
const fs = require("fs");
fs.writeFileSync(`document-${documentId}.zip`, Buffer.from(packageData));
return packageData;
}The downloaded zip file contains:
- The document JSON
- The document XML (UBL format)
- Any binary attachments referenced in the document (for invoices and credit notes)
This is particularly useful when you need to:
- Archive complete documents with all attachments
- Process attachments that were sent with the document
- Access the raw UBL XML for detailed processing (although this is also available in the document JSON)
Best Practices
- Respond quickly: Webhook handlers should acknowledge receipt promptly
- Process asynchronously: Handle complex processing outside the request/response cycle
- Implement retry logic: Be prepared to handle temporary failures
- Monitor webhook health: Keep track of successful and failed webhook deliveries
- Verify signatures: Configure a signing secret and reject deliveries with an invalid signature
- Use idempotency: Process each event exactly once, even if received multiple times
- Handle all event types: Be prepared for new event types in the future
Complete Webhook Integration Example
// Setup authentication
const API_KEY = "your_api_key";
const API_SECRET = "your_api_secret";
const AUTH =
"Basic " + Buffer.from(`${API_KEY}:${API_SECRET}`).toString("base64");
const BASE_URL = "https://app.recommand.eu/api/v1";
const WEBHOOK_SECRET = process.env.RECOMMAND_WEBHOOK_SECRET;
// Create a webhook
async function setupWebhook() {
// 1. Create webhook
const createResponse = await fetch(`${BASE_URL}/webhooks`, {
method: "POST",
headers: {
Authorization: AUTH,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://your-domain.com/peppol-webhook",
companyId: null, // For all companies
secret: WEBHOOK_SECRET,
}),
});
const createResult = await createResponse.json();
if (!createResult.success) {
console.error("Failed to create webhook:", createResult.errors);
return null;
}
console.log(`Webhook created with ID: ${createResult.webhook.documentId}`);
// 2. Verify webhook was created
const listResponse = await fetch(`${BASE_URL}/webhooks`, {
headers: { Authorization: AUTH },
});
const listResult = await listResponse.json();
if (listResult.success) {
console.log("Active webhooks:");
listResult.webhooks.forEach((webhook) => {
console.log(`- ID: ${webhook.documentId}, URL: ${webhook.url}`);
});
}
return createResult.webhook.documentId;
}
// Example Express.js webhook handler
const crypto = require("node:crypto");
const express = require("express");
const app = express();
app.post("/peppol-webhook", express.raw({ type: "application/json" }), async (
req,
res
) => {
// Verify the signature before parsing the request body
const received = Buffer.from(req.get("X-Signature") ?? "");
const expected = Buffer.from(
`sha256=${crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.body)
.digest("hex")}`
);
if (
received.length !== expected.length ||
!crypto.timingSafeEqual(received, expected)
) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
console.log("Received webhook event:", event);
// Immediately acknowledge the webhook
res.status(200).send("Event received");
// Process the event asynchronously
try {
if (event.eventType === "document.received") {
// Get document details
const documentResponse = await fetch(
`${BASE_URL}/documents/${event.documentId}`,
{ headers: { Authorization: AUTH } }
);
const documentResult = await documentResponse.json();
if (documentResult.success) {
const document = documentResult.document;
// Process the document in your system
await processDocument(document);
// Mark as read
await fetch(`${BASE_URL}/documents/${event.documentId}/mark-as-read`, {
method: "POST",
headers: {
Authorization: AUTH,
"Content-Type": "application/json",
},
body: JSON.stringify({ read: true }),
});
console.log(`Document ${event.documentId} processed successfully`);
} else {
console.error("Failed to fetch document:", documentResult.errors);
}
}
} catch (error) {
console.error("Error processing webhook event:", error);
}
});
// Process document function (implement based on your business logic)
async function processDocument(document) {
// Example: Extract document data and save to your database
const { id, direction, senderId, receiverId, type, parsed } = document;
console.log(
`Processing ${direction} ${type} from ${senderId} to ${receiverId}`
);
// Your business logic here...
// - Save to database
// - Create entry in accounting system
// - Notify users
// etc.
}
// Start the server
app.listen(3000, () => {
console.log("Webhook server listening on port 3000");
});
// Setup the webhook
setupWebhook().catch((error) => {
console.error("Error setting up webhook:", error);
});Next Steps
Email Delivery and Notifications
Send documents via email alongside or instead of Peppol, and configure email notifications for incoming and outgoing documents.
Working with Rules
Create automations that send webhooks or emails when supported Peppol events happen, with optional filters for company, document type, labels, and more.