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']}")
auto_register_smp flagPOST /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โ
| Country | Preferred Scheme | Format |
|---|---|---|
| ๐ณ๐ฑ Netherlands | 0106 | 8 digits (KVK) |
| ๐ง๐ช Belgium | 0208 | 10 digits (CBE) |
| ๐ฉ๐ช Germany | 0204 | DE + 9 digits (VAT) |
| ๐ซ๐ท France | 9925 | 14 digits (SIRET) |
| ๐ฎ๐น Italy | 0210 | 11-16 chars (Codice Fiscale) |
| ๐ณ๐ด Norway | 0192 | 9 digits (Org.nr) |
| ๐ธ๐ช Sweden | 0007 | 10 digits (Org.nr) |
| ๐ธ๐ฌ Singapore | 0195 | UEN format |
Validationโ
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โ
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.
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"
]
/register actually publishesThe 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:
| Status | Description |
|---|---|
active | Usable (the default on creation) |
pending | Not yet activated |
inactive | Deactivated โ 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')}")