Invoice Templates
An invoice template is a saved skeleton of an invoice โ the buyer, the line items, the payment terms, whatever you invoice repeatedly โ stored once under a name you choose. Later you apply the template and get that data back, ready to turn into a real invoice. It is the feature behind a "repeat last month's invoice" button.
Templates are private to your organisation. Creating one publishes nothing and sends nothing.
POST /api/v1/templates/{template_id}/apply returns a block of JSON and nothing else. Nothing
is validated, nothing is queued, and nothing reaches the Peppol network. To actually send the
result, you post it yourself to the invoice endpoint โ see
Send an Invoice.
Every endpoint on this page accepts either your organisation's API key or a signed-in portal session, and requires no permission beyond that. Anyone holding a valid key for your organisation can list, create, change, apply and permanently delete templates. This is worth knowing because some of these calls delete data. It is the same arrangement as Saved Parties, and a looser one than the transaction endpoints, which require named permissions.
The endpointsโ
All paths are relative to https://app.goroute.ai/peppol-api. {template_id} is the UUID of
the template.
| Method and path | Purpose |
|---|---|
GET /api/v1/templates | List templates. Favourites first, then most used |
POST /api/v1/templates | Create a template. Returns 201 |
GET /api/v1/templates/search | Search name and description. q is required |
GET /api/v1/templates/favorites | Active templates flagged as favourites, in name order |
POST /api/v1/templates/from-invoice | Build a template from an invoice you already have. Returns 201 |
GET /api/v1/templates/{template_id} | Fetch one template. 404 if it is not yours |
PATCH /api/v1/templates/{template_id} | Change name, description, type, data, favourite or active flag |
DELETE /api/v1/templates/{template_id} | Delete permanently. Returns 204 with no body |
POST /api/v1/templates/{template_id}/favorite | Flip the favourite flag. No request body |
POST /api/v1/templates/{template_id}/apply | Return the invoice data the template produces |
Seven addresses, ten operations. Every one of them answers 401 without a valid credential, and every one that names a single template answers 404 when that template belongs to another organisation โ the same answer you get when it does not exist at all.
Create a templateโ
POST /api/v1/templates โ returns 201 Created.
import requests
BASE_URL = "https://app.goroute.ai/peppol-api"
HEADERS = {"X-API-Key": "your_api_key", "Content-Type": "application/json"}
response = requests.post(
f"{BASE_URL}/api/v1/templates",
headers=HEADERS,
json={
"name": "Monthly retainer - Acme BV",
"description": "Standard monthly consulting retainer",
"template_type": "full",
"is_favorite": True,
"template_data": {
"buyer": {
"name": "Acme BV",
"peppol_id": {"scheme": "0106", "value": "12345678"},
},
"currency": "EUR",
"payment_terms": "Net 30",
"lines": [
{
"description": "Consulting retainer",
"quantity": 1,
"unit_price": 2500.00,
"tax_percent": 21,
}
],
},
},
)
template = response.json()
print(template["id"], template["use_count"])
| Field | Required | Notes |
|---|---|---|
name | Yes | 1 to 255 characters |
template_data | Yes | The saved invoice data. Any JSON object; see the note below |
description | No | Free text |
template_type | No | Defaults to full |
is_favorite | No | Defaults to false |
template_data, we do not check itGoRoute does not validate template_data against the invoice format when you save it. It is
stored exactly as you send it and returned exactly as you sent it. A template holding a field
name that no invoice accepts is saved happily, and only fails later, when you send the applied
result. Check the result of an apply before relying on a template in production โ see
Validation.
Build a template from an invoiceโ
POST /api/v1/templates/from-invoice โ returns 201 Created. Send an invoice you already
have and GoRoute strips the parts that should not repeat.
response = requests.post(
f"{BASE_URL}/api/v1/templates/from-invoice",
headers=HEADERS,
json={
"name": "Repeat of INV-2026-0042",
"invoice": previous_invoice, # the whole invoice object
"exclude_fields": ["purchase_order_reference"],
},
)
Five fields are always dropped, whether you ask for it or not: invoice_number,
issue_date, due_date, tax_point_date and delivery.
Every field whose value is null is also dropped. A field you deliberately set to null on
the original invoice is simply absent from the template.
There is no message listing what was removed. If you compare the template against the invoice it came from, expect it to be smaller.
exclude_fields adds to the list of five โ it does not replace it. There is no way to keep
invoice_number in a template built this way.
This request has no is_favorite field. Create the template first, then flip the flag with
POST /api/v1/templates/{template_id}/favorite.
Apply a templateโ
POST /api/v1/templates/{template_id}/apply โ returns the resulting invoice data as a plain
JSON object, not wrapped in an envelope. The request body is optional.
response = requests.post(
f"{BASE_URL}/api/v1/templates/{template_id}/apply",
headers=HEADERS,
json={"overrides": {"buyer": {"name": "Acme Netherlands BV"}}},
)
invoice_data = response.json()
# invoice_data is yours to post to the invoice endpoint; nothing has been sent
404 if the template does not exist or belongs to another organisation.
How overrides are mergedโ
Overrides are merged into the template field by field, and the merge goes down into nested
objects. In the example above, buyer.name is replaced while every other field inside buyer โ
the Peppol identifier, the address โ survives.
The rule is narrower than it first looks:
- If a key holds an object in both the template and the overrides, the two objects are merged, and the same rule is applied again inside them.
- Anything else is replaced outright. That includes arrays. A
linesoverride replaces the whole list of line items; it does not merge them item by item and it does not append to them. To change one line, send the complete list back with that line changed.
Applying a template changes itโ
apply looks like a read. It is not. Every successful apply:
- increases
use_countby one, and - sets
last_used_atto the time of the call.
Both values come back in later responses, and both affect the order templates are listed in โ so
applying a template repeatedly pushes it up your own list. There is no look-without-touching
mode. If you want to inspect a template without disturbing the count, fetch it with
GET /api/v1/templates/{template_id} and read template_data instead.
List, search and favouritesโ
GET /api/v1/templates returns {"templates": [...], "count": N}.
count is the size of this responsecount is the number of templates in the response you are holding, not the total number you
have stored. If you ask for limit=10 and have 60 templates, count is 10. There is no total
and no page cursor; raise limit to see more, up to 100.
| Parameter | Default | Notes |
|---|---|---|
template_type | none | Optional exact-match filter |
include_inactive | false | Set true to include templates with is_active set to false |
limit | 50 | Between 1 and 100 |
Results come back favourites first, then highest use_count, then name in alphabetical order.
GET /api/v1/templates/search takes q (required, at least one character) and limit (between
1 and 50, default 10). q is a case-insensitive "contains" match against the name and the
description, so retain finds Monthly retainer - Acme BV. Search covers active templates
only and has no template_type filter.
GET /api/v1/templates/favorites takes no parameters and returns active favourites in name
order. A favourite that has been made inactive does not appear here.
Change or remove a templateโ
PATCH /api/v1/templates/{template_id} accepts name, description, template_type,
template_data, is_favorite and is_active. Send only the fields you are changing; anything
you leave out is untouched.
template_data is replaced, not mergedUnlike overrides on an apply, a template_data sent to PATCH overwrites the stored object
completely. To change one field, read the template first, change that field, and send the whole
object back.
POST /api/v1/templates/{template_id}/favorite toggles the flag rather than setting it, so
calling it twice puts you back where you started. The name reads like "make favourite"; it is
not. To set the flag to a known value, use PATCH with {"is_favorite": true} or
{"is_favorite": false}.
Deleting and deactivating are not the same thingโ
These two are one call apart and only one of them can be undone.
PATCHwith{"is_active": false}hides the template from the list, the search and the favourites, but keeps it. You can bring it back, andinclude_inactive=truestill finds it.DELETE /api/v1/templates/{template_id}removes the record permanently. It returns 204 with no body. There is no undo and no recycle bin. 404 if it is not yours.
If you are retiring a template rather than destroying it, deactivate it.
What a template response containsโ
| Field | Meaning |
|---|---|
id | The template's UUID |
name, description | As stored |
template_type | As stored โ see the note below |
template_data | The saved invoice data, exactly as you sent it |
use_count | How many times the template has been applied. Starts at 0 |
last_used_at | When it was last applied, or null if it never has been |
is_favorite | Whether it is pinned to the top of listings |
is_active | false hides it from listings without deleting it |
created_at, updated_at | Timestamps |
template_type is a label, not a checked valueThe product describes three types โ full for a complete invoice, lines for line items only,
and buyer for a buyer plus line items. Nothing enforces those three names. The value is
stored as free text in a 20-character field and is used only as an exact-match filter on the
list endpoint. It does not change how a template is applied or what it may contain. Sending
template_type: "quarterly" is accepted and behaves like any other label.
Organisation invoice defaults โ stored, but never appliedโ
Separately from templates, your organisation has a small block of settings called invoice defaults: a currency, payment terms, a payment means code, a note, a tax rate and a tax category. They look like the answer to "set these once and every invoice gets them". They are not.
Nothing in the product reads these values when you create, validate, prefill or send an invoice.
They are written by PUT, read back by GET, and that is the whole of their effect.
The clearest example is the currency. Auto-fix has a repair called apply_default_currency that
fills in a missing currency โ and it writes a fixed EUR of its own, without ever looking at the
currency you stored here. Whatever you set, an invoice with no currency behaves exactly as if these
settings were empty.
Treat them as a scratchpad your own integration can read and apply itself. If you want every invoice to carry the same currency and payment terms without repeating them, use an invoice template โ the feature documented on the rest of this page โ and apply it. That does prefill an invoice; this does not.
The endpointsโ
All paths are relative to https://app.goroute.ai/peppol-api.
| Method and path | Purpose | Permission |
|---|---|---|
GET /api/v1/settings/company/invoice-defaults | Read the stored defaults | A valid credential for the organisation; no named permission |
PUT /api/v1/settings/company/invoice-defaults | Store defaults | settings:manage |
Both answer 401 without a valid credential. The permissions above are the ones each route declares in the product; this page states what the code requires, not a measurement of what a server enforces.
These defaults are also readable and writable alongside your seller details and your branding
in a single call โ GET and PATCH /api/v1/settings/company. See
reading and writing all your company settings in one call.
The fieldsโ
Every field is optional on the PUT.
| Field | Type | Notes |
|---|---|---|
currency | string | An ISO 4217 code. Not checked against the list |
payment_terms_days | integer | Between 0 and 365. This one is range-checked |
payment_means_code | string | A UNCL 4461 code โ 30 is credit transfer. Not checked against the list |
note | string | Free text |
tax_rate | string | A percentage written as a string, for example "25.00" โ not a number |
tax_category | string | A tax category code such as S, Z or E. Not checked against the list |
Two things that will catch you outโ
PUT behaves like a PATCH. The stored block is merged with what you send, not replaced by it.
A field you leave out keeps its current value, and so does a field you send as null โ nulls are
discarded before the merge. There is no way to clear a single field back to nothing through this
call; send the empty value you want instead, such as "" for note.
The starting values are Northern European, and nobody chose them for you. Before you have ever
called PUT, a GET returns a built-in block rather than an empty one:
{
"currency": "EUR",
"payment_terms_days": 30,
"payment_means_code": "30",
"note": "",
"tax_rate": "25.00",
"tax_category": "S"
}
That is a euro, a 30-day term and a 25% standard rate, regardless of where your organisation is.
It is harmless today only because nothing applies it. If you build your own prefill on top of these
values, set them deliberately first โ an Omani or Nigerian seller who inherits EUR and 25.00
from this block has been given the wrong invoice by their own integration.
Next Stepsโ
- Send an Invoice โ what to do with the applied result
- Saved Parties โ the address book, for storing the buyer rather than the whole invoice
- CSV and Excel Import โ the other way to raise many similar invoices