Skip to main content

Attachments

Peppol documents can include attachments such as PDFs, images, or spreadsheets โ€” a human-readable copy of the invoice, a contract, a timesheet, a photograph of what was delivered. This guide covers both ways of sending one.

Two ways to attach a fileโ€‹

If you send invoices as JSON with POST /api/v1/invoices, put the file in the attachments array and GoRoute writes the XML for you. That is Attachments on the JSON invoice API below, and it is the shorter road.

If you build the XML yourself and send it with POST /api/v1/documents, you write the cac:AdditionalDocumentReference element yourself. The rest of this page โ€” the XML examples, the Python helpers โ€” is about that.

Either way the result on the wire is the same element, and the same rules about what a receiver will accept apply.

Attachments on the JSON invoice APIโ€‹

POST /api/v1/invoices takes an attachments array inside the invoice object. Each entry is one file, either embedded in the document or referenced by URL:

FieldLimit the API enforcesWhat it does
filename255 charactersBecomes the reference's cbc:ID (trimmed to 200 characters) and the filename attribute of the embedded file. Without it the reference is called ATTACHMENT-1, ATTACHMENT-2, and so on
mime_type100 charactersThe mimeCode attribute. Defaults to application/pdf when you leave it out
description500 charactersBecomes cbc:DocumentDescription. Omitted from the XML when you leave it out
content_base64No length limitThe file itself, Base64-encoded, embedded in the document
external_url2000 charactersA link to the file instead of the file. Used only when content_base64 is absent

An entry with neither content_base64 nor external_url is skipped silently โ€” it produces no element at all. Nothing checks that mime_type matches the bytes you send, and nothing checks the MIME type against a list.

Here is the attachments part of a request body, with the rest of the invoice left out โ€” see Sending an Invoice for a whole one:

{
"invoice": {
"number": "INV-2026-00123",
"attachments": [
{
"filename": "INV-2026-00123.pdf",
"mime_type": "application/pdf",
"description": "Invoice PDF - human readable copy",
"content_base64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5n..."
},
{
"filename": "spec-123.pdf",
"description": "Large technical specification",
"external_url": "https://your-server.example/docs/spec-123.pdf"
}
]
}
}

The first entry becomes an embedded file and the second a link:

<cac:AdditionalDocumentReference>
<cbc:ID>INV-2026-00123.pdf</cbc:ID>
<cbc:DocumentDescription>Invoice PDF - human readable copy</cbc:DocumentDescription>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename="INV-2026-00123.pdf">
JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5n...
</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>spec-123.pdf</cbc:ID>
<cbc:DocumentDescription>Large technical specification</cbc:DocumentDescription>
<cac:Attachment>
<cac:ExternalReference>
<cbc:URI>https://your-server.example/docs/spec-123.pdf</cbc:URI>
</cac:ExternalReference>
</cac:Attachment>
</cac:AdditionalDocumentReference>
Attachments sent this way before 24 September 2026 never reached the buyer

The attachments field has always been accepted on POST /api/v1/invoices, but until 24 September 2026 nothing wrote it into the document. The call succeeded, the invoice was delivered, and the attachment was silently dropped on the way. The fix is live on GoRoute's production service and on its test service. If you sent attachments this way before that date, assume the buyer never received them, and send anything that mattered again.

An invoice to an Omani buyer still drops its attachments

Omani invoices are rendered by a different piece of code, which does not read attachments at all. The call succeeds, the invoice is delivered, and the file is not on it โ€” the same silent loss the fix above removed everywhere else. This is a product defect and it is filed with engineering.

Oman has a separate mechanism for the one attachment its rules require, the supporting document behind a re-export: supporting_document_id and supporting_document_uuid, described in the Oman country guide. That is a reference to a document, not the document, so it is not a replacement for attaching a file. Until the defect is fixed, send an Omani buyer anything else out of band.

Embedding Attachmentsโ€‹

Attachments are embedded as Base64-encoded binary content:

<Invoice>
<!-- ... invoice content ... -->

<cac:AdditionalDocumentReference>
<cbc:ID>1</cbc:ID>
<cbc:DocumentDescription>Invoice PDF</cbc:DocumentDescription>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject
mimeCode="application/pdf"
filename="INV-2024-00123.pdf">
JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5n...
</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
</Invoice>

Required Attributesโ€‹

AttributeDescriptionExample
mimeCodeMIME type of the fileapplication/pdf
filenameOriginal filenameinvoice.pdf

Supported MIME Typesโ€‹

MIME TypeExtensionDescription
application/pdf.pdfPDF documents
image/png.pngPNG images
image/jpeg.jpeg, .jpgJPEG images
image/gif.gifGIF images
text/csv.csvCSV data
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.xlsxExcel files
application/vnd.openxmlformats-officedocument.wordprocessingml.document.docxWord files
application/xml.xmlXML files
text/plain.txtPlain text

This list is what receivers commonly handle, not a list GoRoute enforces: nothing in the product checks a MIME type against it, or checks that the type you declare matches the bytes you send. A type outside this list will be transmitted; whether the receiver does anything useful with it is between you and them.

Adding Attachments in Pythonโ€‹

import base64
from pathlib import Path
from lxml import etree

def add_attachment(
invoice_xml: str,
file_path: str,
description: str = "Attachment"
) -> str:
"""Add an attachment to a Peppol invoice."""

# Read and encode the file
file_content = Path(file_path).read_bytes()
encoded_content = base64.b64encode(file_content).decode('utf-8')

# Determine MIME type
mime_types = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.csv': 'text/csv',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}

extension = Path(file_path).suffix.lower()
mime_type = mime_types.get(extension, 'application/octet-stream')
filename = Path(file_path).name

# Parse the invoice
tree = etree.fromstring(invoice_xml.encode())

ns = {
'cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
'cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'
}

# Create the attachment reference
doc_ref = etree.Element('{%s}AdditionalDocumentReference' % ns['cac'])

# ID
id_elem = etree.SubElement(doc_ref, '{%s}ID' % ns['cbc'])
id_elem.text = str(len(tree.findall('.//cac:AdditionalDocumentReference', ns)) + 1)

# Description
desc_elem = etree.SubElement(doc_ref, '{%s}DocumentDescription' % ns['cbc'])
desc_elem.text = description

# Attachment
attachment = etree.SubElement(doc_ref, '{%s}Attachment' % ns['cac'])
binary_obj = etree.SubElement(attachment, '{%s}EmbeddedDocumentBinaryObject' % ns['cbc'])
binary_obj.set('mimeCode', mime_type)
binary_obj.set('filename', filename)
binary_obj.text = encoded_content

# Insert before LegalMonetaryTotal (or at end)
legal_total = tree.find('.//cac:LegalMonetaryTotal', ns)
if legal_total is not None:
legal_total.addprevious(doc_ref)
else:
tree.append(doc_ref)

return etree.tostring(tree, pretty_print=True, xml_declaration=True, encoding='UTF-8').decode()


# Usage
invoice_with_pdf = add_attachment(
invoice_xml,
"invoices/INV-2024-00123.pdf",
description="Invoice PDF"
)

Multiple Attachmentsโ€‹

Add multiple files to a single invoice:

def add_multiple_attachments(
invoice_xml: str,
attachments: list[dict]
) -> str:
"""Add multiple attachments to an invoice.

Args:
invoice_xml: The invoice XML string
attachments: List of {'path': str, 'description': str}

Returns:
Invoice XML with attachments
"""
result = invoice_xml
for attachment in attachments:
result = add_attachment(
result,
attachment['path'],
attachment.get('description', 'Attachment')
)
return result


# Usage
invoice_with_attachments = add_multiple_attachments(
invoice_xml,
[
{'path': 'invoice.pdf', 'description': 'Invoice PDF'},
{'path': 'timesheet.xlsx', 'description': 'Timesheet'},
{'path': 'contract.pdf', 'description': 'Contract Reference'}
]
)

External Referencesโ€‹

For large files, you can include a URL instead of embedding:

<cac:AdditionalDocumentReference>
<cbc:ID>external-doc-1</cbc:ID>
<cbc:DocumentDescription>Large Technical Specification</cbc:DocumentDescription>
<cac:Attachment>
<cac:ExternalReference>
<cbc:URI>https://your-server.com/docs/spec-123.pdf</cbc:URI>
</cac:ExternalReference>
</cac:Attachment>
</cac:AdditionalDocumentReference>
External References

Not all receivers support external references โ€” an accounts-payable system that files what it is sent will file a link, not your document. Where you use one, the URL should be reachable by the receiver and should use HTTPS.

How long you must keep the document at that URL is a question of the retention rules in your country and your buyer's, and GoRoute publishes no figure for it here. If the receiver has to be able to open it years from now, embed the file instead of linking to it.

Best Practicesโ€‹

1. Keep Files Smallโ€‹

GoRoute does not impose a per-file limit, so a check like this one enforces a limit of your choosing โ€” pick it from what you know about your buyers, not from a GoRoute rule:

def check_attachment_size(file_path: str, max_mb: float) -> bool:
"""Check a file against a ceiling you have chosen yourself."""
size_bytes = Path(file_path).stat().st_size
size_mb = size_bytes / (1024 * 1024)

if size_mb > max_mb:
raise ValueError(f"File too large: {size_mb:.2f}MB (your limit: {max_mb}MB)")

return True

2. Compress PDFsโ€‹

import subprocess

def compress_pdf(input_path: str, output_path: str) -> str:
"""Compress a PDF using Ghostscript."""
subprocess.run([
'gs', '-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.4',
'-dPDFSETTINGS=/ebook', # Good quality, smaller size
'-dNOPAUSE', '-dQUIET', '-dBATCH',
f'-sOutputFile={output_path}',
input_path
], check=True)
return output_path

3. Use Meaningful Descriptionsโ€‹

<!-- โŒ Not helpful -->
<cbc:DocumentDescription>file1.pdf</cbc:DocumentDescription>

<!-- โœ… Clear and descriptive -->
<cbc:DocumentDescription>Invoice PDF - Human readable copy</cbc:DocumentDescription>

4. Validate MIME Typesโ€‹

import magic  # python-magic library

def validate_mime_type(file_path: str, expected_extension: str) -> bool:
"""Validate that file content matches its extension."""
detected_mime = magic.from_file(file_path, mime=True)

expected_mimes = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': ['image/jpeg'],
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}

expected = expected_mimes.get(expected_extension)
if expected and detected_mime not in (expected if isinstance(expected, list) else [expected]):
raise ValueError(f"File content ({detected_mime}) doesn't match extension ({expected_extension})")

return True

Size: what is actually enforcedโ€‹

GoRoute sets no size limit of its own on an attachment, and none on a document's attachments taken together. There is no per-file ceiling and no total ceiling. Earlier revisions of this page published figures of 10 MB per file and 15 MB per document; nothing in the product ever enforced them, and they have been removed rather than corrected.

What is real:

LimitValueWhere it applies
filename255 charactersJSON invoice API
mime_type100 charactersJSON invoice API
description500 charactersJSON invoice API
external_url2000 charactersJSON invoice API
The document as a whole300,000,000 charactersGoRoute's AS4 sender, so anything sent over the Peppol network

The last row is the one that can stop a large attachment. Your document travels to the part of GoRoute that puts it on the network as a single string, and that part refuses a string longer than 300 million characters. Base64 encoding inflates a file by roughly a third, so in practice that is a document carrying on the order of 200 MB of original files. Until 24 September 2026 the ceiling was 20 million characters instead, so a document much over about 15 MB failed โ€” and failed with an unhelpful UNKNOWN_ERROR and an empty message.

The receiver's access point has its own limits, and they are usually much smaller

A document GoRoute will happily send can still be refused at the other end. Access points differ in what they accept, some of them by a wide margin, and nothing in the network tells you a receiver's limit in advance. A refusal there is not a validation error on your API call: the call succeeds, and the transaction ends up failed with an error_code and error_message โ€” see Tracking a document.

So: send large files only when you know the receiver accepts them, keep a PDF copy of an invoice down to the size a PDF invoice should be, and link to very large material rather than embedding it.

Keeping files small is still good practice
  • Compress PDFs before attaching
  • Use JPEG for photos (smaller than PNG)
  • Consider linking to external storage for very large files

Complete Exampleโ€‹

import base64
import mimetypes
from pathlib import Path
from lxml import etree

class InvoiceBuilder:
def __init__(self):
self.attachments = []

def add_attachment(
self,
file_path: str,
description: str,
document_type: str = None,
max_mb: float | None = None
):
"""Queue an attachment to be added."""
path = Path(file_path)

# Validate file exists
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")

# Optional: your own ceiling. GoRoute enforces none.
if max_mb is not None:
size_mb = path.stat().st_size / (1024 * 1024)
if size_mb > max_mb:
raise ValueError(f"File too large: {size_mb:.2f}MB (your limit: {max_mb}MB)")

# Detect MIME type
mime_type, _ = mimetypes.guess_type(str(path))
if not mime_type:
mime_type = 'application/octet-stream'

# Read and encode
content = base64.b64encode(path.read_bytes()).decode('utf-8')

self.attachments.append({
'id': len(self.attachments) + 1,
'filename': path.name,
'mime_type': mime_type,
'description': description,
'document_type': document_type,
'content': content
})

return self

def add_pdf_invoice(self, file_path: str):
"""Add the PDF version of the invoice."""
return self.add_attachment(
file_path,
description="Invoice PDF",
document_type="CommercialInvoice"
)

def build_attachments_xml(self) -> list[etree.Element]:
"""Generate XML elements for all attachments."""
ns = {
'cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
'cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2'
}

elements = []
for att in self.attachments:
doc_ref = etree.Element('{%s}AdditionalDocumentReference' % ns['cac'])

etree.SubElement(doc_ref, '{%s}ID' % ns['cbc']).text = str(att['id'])
etree.SubElement(doc_ref, '{%s}DocumentDescription' % ns['cbc']).text = att['description']

if att.get('document_type'):
etree.SubElement(doc_ref, '{%s}DocumentTypeCode' % ns['cbc']).text = att['document_type']

attachment = etree.SubElement(doc_ref, '{%s}Attachment' % ns['cac'])
binary = etree.SubElement(attachment, '{%s}EmbeddedDocumentBinaryObject' % ns['cbc'])
binary.set('mimeCode', att['mime_type'])
binary.set('filename', att['filename'])
binary.text = att['content']

elements.append(doc_ref)

return elements


# Usage
builder = InvoiceBuilder()
builder.add_pdf_invoice("invoices/INV-2024-00123.pdf")
builder.add_attachment("supporting/timesheet.xlsx", "Project Timesheet")
builder.add_attachment("supporting/photo.jpg", "Delivered Goods Photo")

# Get XML elements to insert into invoice
attachment_elements = builder.build_attachments_xml()

Next Stepsโ€‹