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:

  1. Webhooks (recommended): receive real-time notifications as documents arrive
  2. Polling the Inbox: periodically check for new unread documents

Both approaches follow the same processing flow:

  1. Detect a new incoming document
  2. Fetch the full document details
  3. Process the document in your system
  4. Mark the document as read

Prerequisites

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:

  1. Acknowledge the webhook immediately (respond with 200 OK)
  2. Fetch the full document using the documentId
  3. Process the document in your system
  4. 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
Use CaseIntervalNotes
Near-real-time1 minuteHigher API usage
Standard processing5 minutesGood balance for most integrations
Batch processing15–60 minutesFor 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:

FieldDescriptionExample
idUnique document ID"doc_xxx"
companyIdThe company that received the document"c_xxx"
directionAlways "incoming" for received documents"incoming"
typeDocument type: invoice, creditNote, selfBillingInvoice, selfBillingCreditNote, messageLevelResponse, frenchInvoicingCdar, frenchB2CSalesReport, frenchB2CPaymentReport, frenchB2BiInvoiceReport, frenchB2BiPaymentReport, or unknown"invoice"
senderIdPeppol address of the sender"0208:0123456789"
receiverIdPeppol address of the receiver (your company)"0208:9876543210"
parsedThe structured document content (invoice, credit note, etc.)See below
xmlThe raw UBL XML"<?xml ..."
validationValidation results against EN16931 and Peppol BIS 3.0, or null when the document was not validated{ "result": "valid", "errors": [] }
labelsLabels assigned to this document[{ "id": "lab_xxx", "name": "ERP" }]
readAtTimestamp when marked as read, or null"2025-01-15T10:00:00Z"
createdAtWhen the document was received"2025-01-15T09:30:00Z"
peppolMessageIdPeppol AS4 message ID"3f6b1c48-92a7-4f1d-9a1e-0c7f5b2d84e3"
peppolConversationIdPeppol conversation ID"a1d94e7c-5b30-4f89-b6c2-27e0f8149ab5"
envelopeIdSBDH 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:

ValueMeaning
validThe document passed every rule that applies to it
invalidAt least one rule failed; see errors
not_supportedNo ruleset is available for this document type, so nothing was checked
errorThe 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:

FieldDescription
ruleCodeThe 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
errorMessageWhat the rule expected, in the words of the ruleset that raised it
errorLevelHow serious the finding is, for example ERROR. Only findings the ruleset treats as errors make a document invalid
fieldNameWhere in the document the finding applies, usually as an XPath. Null when it is not tied to one place
sourceWhich 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 XML

Previewing 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"
  }
}
FieldRequiredDescription
envelopeIdYesThe envelopeId of the document you are answering. This is what ties the response to the original
responseCodeYesAB to acknowledge receipt, AP to accept the document, RE to reject it
idNoYour own identifier for the response. One is generated when you leave it out
issueDateNoThe 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:

ParameterDescription
companyIdOne company ID, or several to cover more than one company
labelIdOne or more label IDs, so you can list only the documents a routing rule tagged
directionincoming or outgoing
typeA document type, for example invoice
from, toCreation timestamp range, ISO 8601, from inclusive and to exclusive
searchFree text search across the document
isUnreadtrue for documents you have not marked as read, false for the ones you have
envelopeIdThe SBDH instance identifier, useful when you are tracing one transmission
excludeAttachmentsLeaves attachments out of parsed, which keeps the response small when documents carry large PDFs. It works as a bare flag: ?excludeAttachments

Best Practices

  1. Use webhooks when possible: They provide real-time delivery and reduce unnecessary API calls compared to polling.
  2. Always mark documents as read: This keeps your inbox clean and prevents duplicate processing on the next poll or webhook retry.
  3. Process documents idempotently: Your system should handle receiving the same document ID more than once gracefully, whether through webhook retries or overlapping polls.
  4. Store the document ID: Save the Recommand id in your system to track which documents have been processed and to avoid duplicates.
  5. Handle different document types: Incoming documents can be invoices, credit notes, self-billing invoices, or other Peppol document types. Check the type field before processing.
  6. 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