Skip to main content

Participant Registration

Register your organization or your customers as Peppol participants to send and receive documents.

Registration Overviewโ€‹

Registration involves three steps:

1. Create Participant โ†’ 2. Set Document Types โ†’ 3. Register in SMP

Creating the participant record and registering it in the SMP are two separate calls. POST /api/v1/participants stores the record; POST /api/v1/participants/{participant_id}/register publishes it to the SMP and SML.

Create a Participantโ€‹

Basic Registrationโ€‹

import requests

BASE_URL = "https://app.goroute.ai/peppol-api"
HEADERS = {
"X-API-Key": "your_api_key",
"Content-Type": "application/json"
}

UBL_INVOICE = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice"


def register_participant(
scheme: str,
identifier: str,
display_name: str,
country: str,
email: str
) -> dict:
"""Create a Peppol participant, then publish it to the SMP."""

response = requests.post(
f"{BASE_URL}/api/v1/participants",
headers=HEADERS,
json={
"scheme": scheme,
"identifier": identifier,
"display_name": display_name,
"country": country,
"contact_email": email,
"document_types": [UBL_INVOICE]
}
)

response.raise_for_status() # 201 Created
participant = response.json()

# Creating the record does not publish it โ€” register it in the SMP separately.
requests.post(
f"{BASE_URL}/api/v1/participants/{participant['id']}/register",
headers={"X-API-Key": "your_api_key"}
).raise_for_status()

return participant


# Register a Dutch company
participant = register_participant(
scheme="0106",
identifier="12345678",
display_name="My Company BV",
country="NL",
email="invoices@mycompany.nl"
)

print(f"Participant ID: {participant['id']}")
print(f"Peppol ID: {participant['peppol_id']}")
There is no auto_register_smp flag

POST /api/v1/participants only creates the record. SMP and SML registration is POST /api/v1/participants/{participant_id}/register, which takes no request body.

Responseโ€‹

POST /api/v1/participants returns 201 with the participant record:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"org_id": "661f9511-f3ac-52e5-b827-557766551111",
"scheme": "0106",
"identifier": "12345678",
"peppol_id": "0106:12345678",
"display_name": "My Company BV",
"country": "NL",
"contact_email": "invoices@mycompany.nl",
"status": "active",
"smp_registered": false,
"sml_registered": false,
"document_types": [
"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice"
],
"metadata": {},
"verification_required": true,
"entity_identification_complete": false,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}

id is a UUID. smp_registered and sml_registered are booleans, and both are false until the /register call succeeds โ€” there is no smp_status string and no registered_at timestamp on this response. Supported document types are a flat list of document type identifiers in document_types.

Registration Optionsโ€‹

Full Registration Requestโ€‹

participant_data = {
# Required fields
"scheme": "0106",
"identifier": "12345678",

# Identity
"display_name": "My Company BV",
"legal_name": "My Company Besloten Vennootschap",
"legal_identifier_type": "Company Registration",
"country": "NL",
"trade_names": ["MyCo"],

# Contact information
"contact_name": "Jan de Vries",
"contact_email": "invoices@mycompany.nl",
"contact_phone": "+31 20 123 4567",

# Address โ€” flat fields, not a nested object
"address_line1": "Main Street 123",
"city": "Amsterdam",
"postal_code": "1012AB",
"country_subdivision": "NH",

# Proof that the identifier belongs to this end user
"proof_of_ownership_type": "self_declared",

# Document types this participant can receive
"document_types": [
"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice",
"urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote"
],

# Metadata
"metadata": {
"internal_id": "CUST-12345",
"erp_code": "NL-001"
}
}

document_types is a list of strings. The address is a set of flat fields, and the identity and contact fields above are the Entity Identification data OpenPeppol requires of an Access Point about its end users. There is no environment field and no webhook_url on a participant: webhooks are configured once for the organisation at POST /api/v1/webhooks.

Choosing the Right Identifierโ€‹

By Countryโ€‹

CountryPreferred SchemeFormat
๐Ÿ‡ณ๐Ÿ‡ฑ Netherlands01068 digits (KVK)
๐Ÿ‡ง๐Ÿ‡ช Belgium020810 digits (CBE)
๐Ÿ‡ฉ๐Ÿ‡ช Germany0204DE + 9 digits (VAT)
๐Ÿ‡ซ๐Ÿ‡ท France992514 digits (SIRET)
๐Ÿ‡ฎ๐Ÿ‡น Italy021011-16 chars (Codice Fiscale)
๐Ÿ‡ณ๐Ÿ‡ด Norway01929 digits (Org.nr)
๐Ÿ‡ธ๐Ÿ‡ช Sweden000710 digits (Org.nr)
๐Ÿ‡ธ๐Ÿ‡ฌ Singapore0195UEN format

Validationโ€‹

There is no identifier-validation endpoint

Earlier revisions of this page documented a POST route under /api/v1 that validated a scheme and identifier before registration and returned valid, scheme_name and an error. No such endpoint exists.

Check the format yourself against the table above before you register. To find out whether an identifier already resolves on the Peppol network, use GET /api/v1/participants/lookup with a single peppol_id in scheme:value form:

import requests

response = requests.get(
"https://app.goroute.ai/peppol-api/api/v1/participants/lookup",
params={"peppol_id": "0106:12345678"},
headers={"X-API-Key": "your_api_key"},
)

result = response.json()
if result["found"]:
print("Already registered on Peppol:", result["name"])
else:
print("Not on the network:", result["message"])

See Identifiers for the full lookup reference.

Set Document Typesโ€‹

There is no per-capability endpoint

Earlier revisions of this page documented a per-capability POST route under /api/v1/participants/{participant_id} for adding one document type at a time. No such endpoint exists. Document types are a list on the participant record: set them at creation, or replace the list with PATCH /api/v1/participants/{participant_id}.

Update the listโ€‹

def set_document_types(participant_id: str, document_types: list[str]) -> dict:
"""Replace the document types a participant can receive."""
response = requests.patch(
f"{BASE_URL}/api/v1/participants/{participant_id}",
headers=HEADERS,
json={"document_types": document_types}
)
response.raise_for_status()
return response.json()


set_document_types(
"550e8400-e29b-41d4-a716-446655440000",
[
"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice",
"urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote"
]
)

A PATCH replaces document_types wholesale rather than merging, so send the complete list. scheme and identifier cannot be changed after creation.

This list is local metadata โ€” the SMP advertises the curated set

What the network actually sees comes from the registration flow: POST /api/v1/participants/{id}/register, optionally with a document_types selection drawn from GET /peppol/doc-types?country=XX. See SMP Registration โ€” an explicit selection also removes de-selected types from the SMP (reconcile).

Standard document type setsโ€‹

# Billing only (invoices and credit notes)
BILLING_DOCUMENT_TYPES = [
"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice",
"urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote"
]
What /register actually publishes

The list on the record is not what the client dictates to the SMP. The register call chooses the Peppol document type identifiers and process identifiers itself, from the participant's country โ€” AU and NZ participants get PINT A-NZ alongside BIS 3.0, everyone else gets BIS 3.0. You do not pass a process_id per document type from the client. Once the SMP accepts them, those identifiers are merged into the participant's document_types, so the field after registration reflects what was actually published.

Multi-Tenant Registrationโ€‹

For platforms managing multiple participants:

class ParticipantManager:
def __init__(self, api_key: str):
self.api_key = api_key
self.base_url = "https://app.goroute.ai/peppol-api"

def register_customer(
self,
scheme: str,
identifier: str,
display_name: str,
country: str,
email: str,
internal_id: str
) -> dict:
"""Register a customer as a Peppol participant."""

# Check if already registered
existing = self.find_participant(scheme, identifier)
if existing:
return existing

# Create the participant record
response = requests.post(
f"{self.base_url}/api/v1/participants",
headers={
"X-API-Key": self.api_key,
"Content-Type": "application/json"
},
json={
"scheme": scheme,
"identifier": identifier,
"display_name": display_name,
"country": country,
"contact_email": email,
"document_types": BILLING_DOCUMENT_TYPES,
"metadata": {
"internal_id": internal_id
}
}
)
response.raise_for_status()
participant = response.json()

# Publish it to the SMP โ€” a separate call
requests.post(
f"{self.base_url}/api/v1/participants/{participant['id']}/register",
headers={"X-API-Key": self.api_key}
).raise_for_status()

return participant

def find_participant(self, scheme: str, identifier: str) -> dict:
"""Find an existing participant.

The list endpoint pages; it filters only by `status`, so match the
peppol_id yourself.
"""
peppol_id = f"{scheme}:{identifier}"
page = 1

while True:
response = requests.get(
f"{self.base_url}/api/v1/participants",
params={"page": page, "page_size": 100},
headers={"X-API-Key": self.api_key}
)
if response.status_code != 200:
return None

body = response.json()
for item in body.get("items", []):
if item["peppol_id"] == peppol_id:
return item

if not body.get("has_next"):
return None
page += 1


# Usage
manager = ParticipantManager("your_api_key")

# Register customers
for customer in customers:
participant = manager.register_customer(
scheme=customer.peppol_scheme,
identifier=customer.peppol_id,
display_name=customer.company_name,
country=customer.country_code,
email=customer.email,
internal_id=customer.id
)
print(f"Registered: {participant['peppol_id']}")

Registration Statusโ€‹

Check Statusโ€‹

def get_participant(participant_id: str) -> dict:
"""Get participant details and status."""
response = requests.get(
f"{BASE_URL}/api/v1/participants/{participant_id}",
headers={"X-API-Key": "your_api_key"}
)
return response.json()

participant = get_participant("550e8400-e29b-41d4-a716-446655440000")
print(f"Status: {participant['status']}")
print(f"SMP registered: {participant['smp_registered']}")
print(f"SML registered: {participant['sml_registered']}")

Status Valuesโ€‹

status is the state of the local participant record:

StatusDescription
activeUsable (the default on creation)
pendingNot yet activated
inactiveDeactivated โ€” deleting a participant sets this

smp_registered and sml_registered are booleans, not status strings. They move independently: the SML entry is created alongside the SMP ServiceGroup, so sml_registered tracks the ServiceGroup alone, while smp_registered is only true once every document type was accepted as well. A participant with sml_registered: true and smp_registered: false exists on the network but resolves to nothing, and senders addressing it get a transport error.

Check the SMP directlyโ€‹

GET /api/v1/participants/{participant_id}/smp-status queries the SMP rather than reading the local flags:

response = requests.get(
f"{BASE_URL}/api/v1/participants/{participant_id}/smp-status",
headers={"X-API-Key": "your_api_key"}
)

smp = response.json()
print(smp["registered"], smp["participant"])

The response reports registered (boolean) and participant (the scheme:identifier string). When the participant is registered it also carries service_group_xml, the raw SMP ServiceGroup document. Alongside those, the locally recorded state comes back as local_smp_registered, local_sml_registered and local_document_types.

Oman participants are a special case: they live in the OTA's central SMP, which exposes no read route, so this endpoint reports what was recorded at registration time (registered, smp_registered, sml_registered, document_types, rail: "oman-central-smp") plus a note pointing at the Fawtara Portal, SML DNS resolution or a public SMP GET for an authoritative answer.

Error Handlingโ€‹

Common Registration Errorsโ€‹

Errors arrive as a flat JSON body with error, error_code, message and request_id โ€” branch on error_code:

try:
participant = register_participant(...)
except requests.HTTPError as e:
error = e.response.json()

if error["error_code"] == "DUPLICATE_PARTICIPANT":
print("This participant already exists in your organization")
# Return existing participant instead

elif error["error_code"] == "PARTICIPANT_IDENTITY_INVALID":
# Wrong scheme for the country, or a failed check digit โ€” fix the
# identifier you have rather than retrying with a new one.
print(f"Invalid identifier: {error['message']}")

elif error["error_code"] == "SMP_REGISTRATION_FAILED":
# The record exists; publishing it to the SMP did not succeed.
print(f"SMP registration failed: {error.get('cause')}")

Next Stepsโ€‹