Receiving Documents
Process incoming Peppol documents using webhooks or polling. Covers real-time webhook delivery, inbox polling, fetching document details, and marking documents as read.
This guide explains how to receive and process incoming Peppol documents using the Recommand API. It covers the two main approaches, webhooks (push) and inbox polling (pull). It also explains how to retrieve and process document details.
Overview
When a business partner sends you a document through the Peppol network, Recommand automatically receives, validates, and stores it. To consume these documents in your system, you have two options:
- Webhooks (recommended): receive real-time notifications as documents arrive
- Polling the Inbox: periodically check for new unread documents
Both approaches follow the same processing flow:
- Detect a new incoming document
- Fetch the full document details
- Process the document in your system
- Mark the document as read
Prerequisites
- A Recommand account with API access
- Your API key and secret
- At least one registered company with a Peppol identifier and document types configured
Option 1: Webhooks (Recommended)
Webhooks push a notification to your server the moment a document arrives. This is the recommended approach because it gives you real-time delivery without unnecessary API calls.
For a detailed guide on setting up webhook endpoints, registering webhooks, and managing them, see the Working with Webhooks guide. This section focuses on the document processing flow.
How It Works
When a document is received, Recommand sends a POST request to your registered webhook URL:
{
"eventType": "document.received",
"documentId": "doc_xxx",
"teamId": "team_xxx",
"companyId": "c_xxx"
}Your handler should:
- Acknowledge the webhook immediately (respond with
200 OK) - Fetch the full document using the
documentId - Process the document in your system
- Mark the document as read
Example: Webhook Handler
app.post("/peppol-webhook", async (req, res) => {
const event = req.body;
// Acknowledge immediately
res.status(200).send("OK");
if (event.eventType === "document.received") {
// Fetch, process, and mark as read
const document = await fetchDocument(event.documentId);
await processInYourSystem(document);
await markAsRead(event.documentId);
}
});When to Use Webhooks
- You need real-time processing of incoming documents
- Your system can expose a publicly accessible HTTPS endpoint
- You want to minimize API calls
Option 2: Polling the Inbox
If you cannot expose a public endpoint for webhooks, you can poll the inbox at regular intervals to check for new documents.
How It Works
The inbox endpoint returns all incoming documents that have not been marked as read. Once you process a document, mark it as read so it no longer appears in the inbox on the next poll.
The inbox is not paginated. Every unread document comes back in one response, so
marking documents as read is what keeps the response a sensible size. If you want
a paged view of unread documents instead, use the list documents
endpoint with isUnread=true.
async function pollInbox() {
const response = await fetch("https://app.recommand.eu/api/v1/inbox", {
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
});
const result = await response.json();
return result.documents; // Array of unread incoming documents
}You can optionally filter by company:
const response = await fetch(
"https://app.recommand.eu/api/v1/inbox?companyId=c_xxx",
{
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
}
);Example: Polling Loop
const POLL_INTERVAL = 60_000; // 1 minute
async function startPolling() {
while (true) {
const documents = await pollInbox();
for (const doc of documents) {
const fullDocument = await fetchDocument(doc.id);
await processInYourSystem(fullDocument);
await markAsRead(doc.id);
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
}
}
startPolling();When to Use Polling
- Your system cannot expose a public webhook endpoint
- You prefer a simpler architecture without inbound HTTP handling
- Near-real-time processing is not required
Recommended Polling Intervals
| Use Case | Interval | Notes |
|---|---|---|
| Near-real-time | 1 minute | Higher API usage |
| Standard processing | 5 minutes | Good balance for most integrations |
| Batch processing | 15–60 minutes | For systems that process documents in bulk |
Fetching Document Details
Both approaches require fetching the full document to access its content. The inbox and webhook payloads provide a document ID but not the full parsed document.
Use the get document endpoint to retrieve the complete document:
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;
}Document Response Structure
The document object contains the following key fields:
| Field | Description | Example |
|---|---|---|
id | Unique document ID | "doc_xxx" |
companyId | The company that received the document | "c_xxx" |
direction | Always "incoming" for received documents | "incoming" |
type | Document type: invoice, creditNote, selfBillingInvoice, selfBillingCreditNote, messageLevelResponse, frenchInvoicingCdar, frenchB2CSalesReport, frenchB2CPaymentReport, frenchB2BiInvoiceReport, frenchB2BiPaymentReport, or unknown | "invoice" |
senderId | Peppol address of the sender | "0208:0123456789" |
receiverId | Peppol address of the receiver (your company) | "0208:9876543210" |
parsed | The structured document content (invoice, credit note, etc.) | See below |
xml | The raw UBL XML | "<?xml ..." |
validation | Validation results against EN16931 and Peppol BIS 3.0, or null when the document was not validated | { "result": "valid", "errors": [] } |
labels | Labels assigned to this document | [{ "id": "lab_xxx", "name": "ERP" }] |
readAt | Timestamp when marked as read, or null | "2025-01-15T10:00:00Z" |
createdAt | When the document was received | "2025-01-15T09:30:00Z" |
peppolMessageId | Peppol AS4 message ID | "3f6b1c48-92a7-4f1d-9a1e-0c7f5b2d84e3" |
peppolConversationId | Peppol conversation ID | "a1d94e7c-5b30-4f89-b6c2-27e0f8149ab5" |
envelopeId | SBDH instance identifier | "7c2e5a19-64bd-4c73-8f05-91ab3de60f27" |
Validation Results
validation reports what Recommand's validator made of the document. It is
null when the document was never validated.
{
"result": "valid",
"errors": []
}result is one of:
| Value | Meaning |
|---|---|
valid | The document passed every rule that applies to it |
invalid | At least one rule failed; see errors |
not_supported | No ruleset is available for this document type, so nothing was checked |
error | The validation service could not be reached, or its answer could not be read |
errors is empty when the document is valid, and also when the result is
not_supported or error. Each entry has the shape:
| Field | Description |
|---|---|
ruleCode | The identifier of the rule that was violated, for example PEPPOL-EN16931-R010. Null for findings that do not come from a coded rule, such as a schema error |
errorMessage | What the rule expected, in the words of the ruleset that raised it |
errorLevel | How serious the finding is, for example ERROR. Only findings the ruleset treats as errors make a document invalid |
fieldName | Where in the document the finding applies, usually as an XPath. Null when it is not tied to one place |
source | Which ruleset produced the finding |
A document that fails validation is still received and stored. Validation tells you what your counterparty sent, it does not reject the document.
Accessing the Parsed Content
The parsed field contains the structured representation of the document. For an invoice, this includes all the fields you would find when sending an invoice: parties, lines, amounts, VAT, payment information, and so on.
const document = await fetchDocument("doc_xxx");
if (document.type === "invoice") {
const invoice = document.parsed;
console.log("Invoice number:", invoice.invoiceNumber);
console.log("Issue date:", invoice.issueDate);
console.log("Seller:", invoice.seller.name);
console.log("Buyer:", invoice.buyer.name);
for (const line of invoice.lines) {
console.log(`- ${line.name}: ${line.netPriceAmount}`);
}
}
if (document.type === "creditNote") {
const creditNote = document.parsed;
console.log("Credit note number:", creditNote.creditNoteNumber);
}Retrieving the Raw XML
If you need the raw UBL XML for archival or processing in another system, it is included in the xml field of the document response. You can also request just the XML by setting the Accept header:
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"),
Accept: "application/xml",
},
}
);
const xml = await response.text(); // Raw UBL XMLPreviewing a Document
If you want to show a person the document rather than process it, use the render document endpoint. It turns a stored document into something readable:
GET https://app.recommand.eu/api/v1/documents/{documentId}/render/{type}type is either html or pdf. HTML is handy for embedding a preview in your
own interface; PDF is what you hand a user to download or attach to an email.
const response = await fetch(
`https://app.recommand.eu/api/v1/documents/${documentId}/render/pdf`,
{
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
}
);
const pdf = await response.arrayBuffer();The rendering is generated from the document, so it works for documents you received as well as documents you sent, and you do not need to keep a copy of your own.
Marking Documents as Read
After processing a document, mark it as read using the mark as read endpoint. This removes it from the inbox and signals that your system has consumed it.
async function markAsRead(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 }),
}
);
return response.json();
}You can also mark a document as unread again by passing { "read": false }.
Message Level Responses
A message level response is the formal answer to a document you received. It tells the sender that their document arrived and whether you accepted it. Send one when the sender needs that answer inside Peppol rather than by email or phone, for example because your counterparty tracks acknowledgements, or because you are rejecting a document and want the rejection on the record.
It is not a substitute for a credit note. A message level response says something about the message; a credit note corrects the invoice.
Send it with the send document endpoint using
documentType: "messageLevelResponse":
{
"recipient": "0208:0123456789",
"documentType": "messageLevelResponse",
"document": {
"envelopeId": "7c2e5a19-64bd-4c73-8f05-91ab3de60f27",
"responseCode": "AP",
"issueDate": "2025-01-15"
}
}| Field | Required | Description |
|---|---|---|
envelopeId | Yes | The envelopeId of the document you are answering. This is what ties the response to the original |
responseCode | Yes | AB to acknowledge receipt, AP to accept the document, RE to reject it |
id | No | Your own identifier for the response. One is generated when you leave it out |
issueDate | No | The date of the response. Defaults to today |
Message level responses never count towards your document quota, in either direction. Sending one is free, and one that arrives for a document you sent is free too. They are transport receipts, not business documents, so you can acknowledge everything you receive without watching your usage.
Listing Past Documents
To query documents beyond the inbox (including already-read documents), use the list documents endpoint with a direction filter:
const response = await fetch(
"https://app.recommand.eu/api/v1/documents?direction=incoming&limit=25&page=1",
{
headers: {
Authorization:
"Basic " +
Buffer.from("your_api_key:your_api_secret").toString("base64"),
},
}
);
const result = await response.json();
console.log(`Total incoming documents: ${result.pagination.total}`);limit defaults to 10 and accepts up to 200 documents per page.
You can further filter with:
| Parameter | Description |
|---|---|
companyId | One company ID, or several to cover more than one company |
labelId | One or more label IDs, so you can list only the documents a routing rule tagged |
direction | incoming or outgoing |
type | A document type, for example invoice |
from, to | Creation timestamp range, ISO 8601, from inclusive and to exclusive |
search | Free text search across the document |
isUnread | true for documents you have not marked as read, false for the ones you have |
envelopeId | The SBDH instance identifier, useful when you are tracing one transmission |
excludeAttachments | Leaves attachments out of parsed, which keeps the response small when documents carry large PDFs. It works as a bare flag: ?excludeAttachments |
Best Practices
- Use webhooks when possible: They provide real-time delivery and reduce unnecessary API calls compared to polling.
- Always mark documents as read: This keeps your inbox clean and prevents duplicate processing on the next poll or webhook retry.
- Process documents idempotently: Your system should handle receiving the same document ID more than once gracefully, whether through webhook retries or overlapping polls.
- Store the document ID: Save the Recommand
idin your system to track which documents have been processed and to avoid duplicates. - Handle different document types: Incoming documents can be invoices, credit notes, self-billing invoices, or other Peppol document types. Check the
typefield before processing. - Use labels for routing: Combine with Suppliers and Labels to automatically route documents to the right system or workflow based on the sender.
Next Steps
Email Delivery and Notifications
Forward incoming documents to email or accounting software automatically.
Working with Webhooks
Set up webhook endpoints and manage webhook registrations.
Suppliers and Labels
Route incoming documents automatically based on supplier labels.
Documents API Reference
Explore all document endpoints including inbox, get, list, and mark as read.