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:
| Field | Limit the API enforces | What it does |
|---|---|---|
filename | 255 characters | Becomes 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_type | 100 characters | The mimeCode attribute. Defaults to application/pdf when you leave it out |
description | 500 characters | Becomes cbc:DocumentDescription. Omitted from the XML when you leave it out |
content_base64 | No length limit | The file itself, Base64-encoded, embedded in the document |
external_url | 2000 characters | A 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>
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.
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โ
| Attribute | Description | Example |
|---|---|---|
mimeCode | MIME type of the file | application/pdf |
filename | Original filename | invoice.pdf |
Supported MIME Typesโ
| MIME Type | Extension | Description |
|---|---|---|
application/pdf | PDF documents | |
image/png | .png | PNG images |
image/jpeg | .jpeg, .jpg | JPEG images |
image/gif | .gif | GIF images |
text/csv | .csv | CSV data |
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | .xlsx | Excel files |
application/vnd.openxmlformats-officedocument.wordprocessingml.document | .docx | Word files |
application/xml | .xml | XML files |
text/plain | .txt | Plain 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>
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:
| Limit | Value | Where it applies |
|---|---|---|
filename | 255 characters | JSON invoice API |
mime_type | 100 characters | JSON invoice API |
description | 500 characters | JSON invoice API |
external_url | 2000 characters | JSON invoice API |
| The document as a whole | 300,000,000 characters | GoRoute'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.
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.
- 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()