Skip to main content

CSV and Excel Import

Upload a spreadsheet of invoices and GoRoute turns each row into a Peppol document and sends it. This is the path for teams that cannot integrate with the API directly: export from your accounting system, check the file, upload it.

The import accepts both CSV files and native Excel workbooks (.xlsx). An uploaded workbook is converted to CSV internally, so both formats run through exactly the same parsing, validation and identity checks.

Two different bulk mechanisms

This page covers file import under /api/v1/invoices/import/csv, where you upload a spreadsheet. Batch Sending covers a different mechanism under /api/v1/batches, where you build a batch from documents you already hold. They are not interchangeable.

The endpointsโ€‹

MethodPathPurpose
GET/api/v1/invoices/import/csv/templateDownload a sample file to fill in
GET/api/v1/invoices/import/csv/columnsList every column name and alias the importer accepts
POST/api/v1/invoices/import/csv/previewParse and fully validate without sending
POST/api/v1/invoices/import/csv/validation-reportTurn preview results into a downloadable error report
POST/api/v1/invoices/import/csvImport and send, asynchronously

Preview and import require the csv_import feature to be enabled for your organisation, and the invoices:send permission. The template and column endpoints require invoices:read.

Preview first. The preview endpoint validates every invoice in the file against the same compliance stack the send path uses, so anything that would be rejected after sending is visible before you commit to it.

  1. Download the template so your column headers match.
  2. Preview the file. Fix what comes back.
  3. Import the file. This returns immediately with a job ID.
  4. Poll the job until it finishes, then fetch the result.

1. Download a templateโ€‹

curl -X GET "https://app.goroute.ai/peppol-api/api/v1/invoices/import/csv/template" \
-H "X-API-Key: YOUR_API_KEY" \
-o invoice_template_simple.csv

Two query parameters change what you get:

  • format โ€” simple (default) puts one invoice on one row. multi produces a multi-line layout, where several rows sharing an invoice_number become one invoice with several lines.
  • filetype โ€” csv (default), or xlsx for a native Excel workbook.

The template is tenant-aware. Organisations registered in Oman are given the additional Fawtara classification columns (document_type, om_invoice_kind, seller_identifier and the import, export and item-classification columns); other organisations are given a leaner template without them.

2. Check which columns are acceptedโ€‹

curl -X GET "https://app.goroute.ai/peppol-api/api/v1/invoices/import/csv/columns" \
-H "X-API-Key: YOUR_API_KEY"

This returns every field the importer understands, the aliases accepted for it, whether it is required, and a short description. It is the authoritative list โ€” it is generated from the parser itself, so it cannot drift from what the importer actually does.

Required columnsโ€‹

Four columns must be present and populated:

ColumnMeaning
invoice_numberUnique invoice identifier, for example INV-2026-001
buyer_peppol_idCustomer's Peppol participant ID, as scheme:value
buyer_nameCustomer's company name
line_descriptionDescription of the product or service

Commonly used optional columnsโ€‹

Everything else is optional. Blank means "a standard invoice".

ColumnMeaning
issue_dateDate the invoice was issued; YYYY-MM-DD is preferred
due_datePayment due date
currencyThree-letter currency code
buyer_referenceThe customer's purchase order number or reference
notesAdditional notes to appear on the invoice
seller_peppol_id, seller_name, seller_vat, seller_countryYour own details
buyer_vat, buyer_country, buyer_street, buyer_cityMore customer details
line_quantityQuantity; defaults to 1
line_unitUnit of measure, for example EA, HUR, DAY
line_unit_pricePrice per unit
line_tax_rateTax rate as a percentage, for example 25 for 25%
line_tax_categoryTax category for the line โ€” see Zero-rated and exempt lines
total_payableTotal amount due; calculated if you leave it out
payment_iban, payment_bic, payment_referencePayment details

If your seller details are missing from the file, they are filled in from your organisation's company settings.

Column names are flexibleโ€‹

Most columns accept several spellings, so an export from your accounting system often works without renaming anything. invoice_number also accepts invoice_no, inv_num, number, invoice # and document_number. line_quantity also accepts quantity and qty. buyer_name also accepts customer_name, customer, recipient and to_name. Call the columns endpoint for the full mapping rather than guessing.

Zero-rated and exempt linesโ€‹

A tax rate of zero does not say enough. Zero-rated and exempt are two different tax categories with two different reason-code lists, and a line sent under the wrong one is rejected. Three columns let you state which you mean.

ColumnAlso accepted asWhat it is for
line_tax_categorytax_category, vat_categorySays outright which category the line is
line_tax_exemption_reason_codevat_exemption_reason_code, tax_exemption_reason_code, exemption_codeThe official code that justifies the zero rate or the exemption
line_tax_exemption_reasonvat_exemption_reason, tax_exemption_reason, exemption_reasonThe same justification in words

line_tax_category accepts standard, zero, zero_rated, exempt, export, outside_scope and out_of_scope. Capitalisation and surrounding spaces are ignored.

How the importer decidesโ€‹

For each line, in this order:

  1. If line_tax_category has a value, that value wins โ€” whatever the rate says.
  2. Otherwise, if the rate is zero or missing, the row's own reason code decides: a code beginning VATEX makes the line exempt, anything else makes it zero-rated.
  3. Otherwise the line is standard.

So a spreadsheet that already carries reason codes gets the right answer without a new column. Add line_tax_category when you want to be explicit, or when a line is exempt but carries no code beginning VATEX.

Two categories cannot be set from a spreadsheet

The platform recognises seven tax categories. This column reaches five of them. Reduced rate and reverse charge have no spelling here and cannot be selected through a CSV or Excel import at all.

A value the importer does not recognise is worse than a rejection: it records an error against the row and falls back to standard, and the row is still imported and sent. Read the preview before you import, and treat a tax-category error as a stop.

Oman: a reason code is mandatoryโ€‹

The Oman rulebook GoRoute ships and runs states it as a fatal rule:

[IBR-069-OM] - A VAT breakdown (IBG-23) with VAT Category code (IBT-118) "E" and/or "Z" must have a VAT exemption reason code (IBT-121).

In plain terms: every exempt or zero-rated line on an Omani invoice must carry a code in line_tax_exemption_reason_code, or the invoice fails validation. The two lists are separate โ€” zero-rating codes and exemption codes are not interchangeable. The Oman country guide lists both, and is the one place to look them up.

This quotation is taken from the Schematron rule file inside the product, not from a page on the Oman Tax Authority's website.

3. Preview before sendingโ€‹

import requests

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://app.goroute.ai/peppol-api"

with open("invoices.csv", "rb") as fh:
response = requests.post(
f"{BASE_URL}/api/v1/invoices/import/csv/preview",
headers={"X-API-Key": API_KEY},
files={"file": ("invoices.csv", fh, "text/csv")},
)

preview = response.json()
print(preview["total_rows"], "rows")
print(preview["valid_invoices"], "valid,", preview["invalid_invoices"], "invalid")
print(preview["skipped_rows"], "rows skipped at parse time")

The response reports:

  • total_rows, valid_invoices, invalid_invoices, total_errors and total_warnings.
  • skipped_rows โ€” data rows dropped during parsing, for example a row with no invoice number. This is reported separately so that seventeen rows becoming four invoices is explained rather than looking like lost data.
  • column_mapping and unmapped_columns โ€” which of your headers were recognised, and which were ignored.
  • invoice_validations โ€” the full validation result for every invoice, not just the first few.

Every invoice is validated against the complete stack: UBL 2.1 structure, calculation and business rules, eDEC code lists, CEN-EN16931 and Peppol BIS 3.0 Schematron rules, and any national extension that applies to the receiver's country.

4. Download an error reportโ€‹

For a file with many problems, turn the preview result into a report someone can work through in a spreadsheet.

response = requests.post(
f"{BASE_URL}/api/v1/invoices/import/csv/validation-report",
headers={"X-API-Key": API_KEY},
json={"invoice_validations": preview["invoice_validations"], "format": "csv"},
)

with open("validation_report.csv", "wb") as fh:
fh.write(response.content)

format accepts csv for a flat spreadsheet with one row per error, or json for the full structured report. The CSV columns are invoice number, status, layer, rule ID, severity, message, XPath and field.

5. Import and sendโ€‹

with open("invoices.csv", "rb") as fh:
response = requests.post(
f"{BASE_URL}/api/v1/invoices/import/csv",
headers={"X-API-Key": API_KEY},
files={"file": ("invoices.csv", fh, "text/csv")},
)

job = response.json() # HTTP 202
job_id = job["job_id"]
print(job_id, job["total_rows"], job["status_url"])

This returns 202 Accepted, not a completed result. The file is parsed immediately โ€” headers checked, rows counted, your default seller resolved โ€” and then an import job is created. The heavy work per row (rendering the Peppol XML, signing it where the jurisdiction requires it, running Schematron validation and queueing for delivery) happens in the background, so the request returns within seconds even for tens of thousands of rows.

The maximum upload size is 100 MB.

Poll the jobโ€‹

The 202 response carries status_url and result_url. Poll the status until it reaches a terminal value โ€” validated, partially_validated or failed โ€” then fetch the result.

import time

while True:
status = requests.get(
f"{BASE_URL}/api/v1/import-jobs/{job_id}",
headers={"X-API-Key": API_KEY},
).json()

if status["status"] in ("validated", "partially_validated", "failed"):
break
time.sleep(2)

result = requests.get(
f"{BASE_URL}/api/v1/import-jobs/{job_id}/result.json",
headers={"X-API-Key": API_KEY},
).json()

partially_validated means some rows were accepted and others rejected. The result payload lists the transactions created and the errors for the rows that did not make it.

Multi-line invoicesโ€‹

In the multi template layout, several rows that share the same invoice_number are grouped into a single invoice with several lines. This grouping is applied on both the preview and the import path, so what you see in the preview is what is sent.

Next Stepsโ€‹