Skip to main content

Partner Verification

Two different things share the word "verification" on the Peppol network, and they are not the same feature. Read this first, because the rest of the page depends on which one you want.

You want to knowFeatureWhere
"Is my integration correct, and can I display the GoRoute Verified badge?"GoRoute Verified โ€” self-service integration verificationThis page
"Is my trading partner registered on Peppol and can they receive my document type?"Participant lookupParticipant Lookup and the section below
Available

GoRoute Verified integration verification is built and available today: POST /api/v1/partners/verify, GET /api/v1/partners/verification and GET /api/v1/partners/verified/{org_id}.

GoRoute Verified โ€” integration verificationโ€‹

You verify your own integration. You send test documents to five sandbox scenarios, then ask GoRoute to check that you handled each one correctly. Pass all five and your organisation is marked verified for 365 days.

The five required scenariosโ€‹

All five must pass. There are no other scenarios and no partial badge.

ScenarioWhat you must doHow GoRoute checks it
test-successSend a document to 9999:test-successA transaction to that receiver with status delivered in the last 30 days
test-recipient-not-foundSend a document to 9999:test-recipient-not-found and handle the failureA transaction to that receiver with status failed
test-timeoutSend a document to 9999:test-timeout and handle the timeoutA transaction to that receiver with status failed
test-duplicate-eventSend to 9999:test-duplicate-event and deduplicate the repeated webhookA transaction to that receiver with status delivered
webhook-signatureReceive a webhook and verify its HMAC-SHA256 signatureA webhook delivery to your registered endpoint with a valid signature

Transactions are matched within a 30-day window, so run the scenarios and request verification in the same month.

Request verificationโ€‹

POST /api/v1/partners/verify โ€” requires the org:manage permission.

Both body fields are optional. If you send none of them, GoRoute looks at your recent sandbox transactions.

FieldTypeRequiredMeaning
webhook_urlstringNoThe webhook URL to use for the webhook-signature scenario. Must match a registered webhook endpoint. Max 2048 characters.
transaction_idsarray of stringNoRestrict the check to specific transactions. Maximum 5.
import requests

response = requests.post(
"https://app.goroute.ai/peppol-api/api/v1/partners/verify",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
json={
"webhook_url": "https://partner.example.com/webhooks/peppol",
},
)

result = response.json()

if result["verified"]:
print("Verified until", result["verified_until"])
print("Badge URL:", result["badge_url"])
else:
print("Still failing:", result["failed"])
print("Not yet attempted:", result["remaining"])

Verification responseโ€‹

{
"verified": true,
"status": "verified",
"passed": [
"test-success",
"test-recipient-not-found",
"test-timeout",
"test-duplicate-event",
"webhook-signature"
],
"failed": [],
"remaining": [],
"results": [
{
"scenario": "test-success",
"passed": true,
"transaction_id": "550e8400-e29b-41d4-a716-446655440000",
"error": null,
"verified_at": "2026-08-05T10:30:00Z"
}
],
"badge_url": "https://goroute.ai/assets/goroute-verified.svg?token=...",
"verified_until": "2027-08-05T10:30:00Z",
"next_attempt_allowed_at": null,
"attempts_remaining_today": 9
}
FieldTypeMeaning
verifiedbooleanTrue only when all five scenarios passed
statusstringpending, verified, expired or revoked
passedarrayScenario names that passed
failedarrayScenario names that were attempted and failed
remainingarrayScenario names not yet attempted
resultsarrayPer-scenario detail: scenario, passed, transaction_id, error, verified_at
badge_urlstring or nullBadge URL carrying your badge token. Present only while verified โ€” see Verification Badge
verified_untilstring or nullExpiry, 365 days after the badge was granted
next_attempt_allowed_atstring or nullSet when you are rate limited
attempts_remaining_todayintegerAttempts left in the current 24-hour window

Rate limitโ€‹

10 verification attempts per organisation per 24 hours. Exceeding it returns HTTP 429:

{
"detail": {
"error": "rate_limit_exceeded",
"error_code": "VERIFICATION_RATE_LIMIT",
"message": "Maximum 10 verification attempts per day",
"next_attempt_allowed_at": "2026-08-06T00:00:00Z",
"attempts_today": 10
}
}

Check your current statusโ€‹

GET /api/v1/partners/verification โ€” requires the org:manage permission. It does not consume a verification attempt.

status = requests.get(
"https://app.goroute.ai/peppol-api/api/v1/partners/verification",
headers={"X-API-Key": "your_api_key"},
).json()

print(status["status"], status["passed_scenarios"], status["remaining_scenarios"])
FieldTypeMeaning
statusstringpending, verified, expired or revoked
verifiedbooleanWhether currently verified
passed_scenariosarrayScenarios that have passed
remaining_scenariosarrayScenarios not yet passed
verified_atstring or nullWhen verification was granted
verified_untilstring or nullWhen it expires
badge_urlstring or nullBadge URL if verified
attempts_todayintegerAttempts used today
max_attempts_per_dayinteger10

Public lookup of a verified organisationโ€‹

GET /api/v1/partners/verified/{org_id} โ€” no authentication required. {org_id} is an organisation UUID or slug.

curl https://app.goroute.ai/peppol-api/api/v1/partners/verified/acme-corp
{
"verified": true,
"status": "verified",
"organization_id": "550e8400-e29b-41d4-a716-446655440000",
"organization_name": "Acme Corp",
"verified_since": "2026-08-05T10:30:00Z",
"verified_until": "2027-08-05T10:30:00Z",
"status_checked_at": "2026-08-05T12:00:00Z",
"revocation_reason": null
}
  • Rate limit: 60 requests per minute per IP. Responses are cached for 5 minutes โ€” use status_checked_at to know how fresh the answer is.
  • Anti-enumeration: an unknown organisation, and an organisation that has never been verified, both return 404. A 404 does not distinguish "no such organisation" from "not verified".

Checking a trading partner on the Peppol networkโ€‹

This is the other question, and it is a different endpoint. To find out whether a trading partner is registered and what they can receive, use participant lookup:

GET /api/v1/participants/lookup โ€” pass peppol_id (preferred) or identifier, in scheme:value form.

import requests

def lookup_partner(peppol_id: str) -> dict:
"""Look a trading partner up on the Peppol network."""
response = requests.get(
"https://app.goroute.ai/peppol-api/api/v1/participants/lookup",
params={"peppol_id": peppol_id},
headers={"X-API-Key": "your_api_key"},
)
return response.json()


result = lookup_partner("0106:12345678")

if result["found"]:
print("Found:", result["name"], result["country"])
print("Can receive:", ", ".join(result["capabilities"]))
else:
print("Not found:", result["message"])
FieldTypeMeaning
foundbooleanWhether the participant resolved on the network
participant_idstringThe scheme:value you asked about
namestring or nullBusiness name, if the SMP returned one
countrystring or nullCountry code, if the SMP returned one
capabilitiesarray of stringDocument type identifiers the participant can receive
messagestring or nullExplanation when found is false
The value must contain a colon

A request that is not in scheme:value form returns 400 with Provide peppol_id as scheme:value, e.g. 0248:OM1100099003. Passing the scheme and the identifier as two separate query parameters does not work.

Verifying multiple partnersโ€‹

There is no batch verification endpoint. Look partners up one at a time and keep your request rate within your plan's rate limit.

def lookup_many(peppol_ids: list[str]) -> list[dict]:
"""Look several partners up by looking each one up in turn."""
return [
{"peppol_id": pid, "lookup": lookup_partner(pid)}
for pid in peppol_ids
]


for entry in lookup_many([
"0106:12345678",
"0106:87654321",
"0208:0123456789",
]):
status = "โœ…" if entry["lookup"]["found"] else "โŒ"
print(f"{status} {entry['peppol_id']}")

Integration patternsโ€‹

Pre-send checkโ€‹

Confirm the receiver is reachable before you send:

class SecureSender:
def send_invoice(self, receiver_peppol_id: str, ubl_xml: str) -> dict:
lookup = lookup_partner(receiver_peppol_id)

if not lookup["found"]:
raise ValueError(f"Receiver not on the network: {lookup['message']}")

if not lookup["capabilities"]:
raise ValueError("Receiver advertises no document types")

return self.do_send(receiver_peppol_id, ubl_xml)

An SMP lookup costs a network round trip. Cache the result for the lifetime of a batch rather than looking the same receiver up once per invoice.

Onboardingโ€‹

async def onboard_partner(partner_data: dict) -> dict:
"""Onboard a new trading partner after a network lookup."""
lookup = lookup_partner(partner_data["peppol_id"])

if not lookup["found"]:
return {
"status": "manual_review",
"reason": lookup["message"],
"message": "Partner is not resolvable on the Peppol network",
}

partner = await create_partner(**partner_data, capabilities=lookup["capabilities"])
return {"status": "active", "partner_id": partner.id}

Periodic re-checkโ€‹

Participants leave the network and change capabilities. Re-run the lookup on a schedule โ€” GoRoute does not push you a notification when a trading partner's registration changes.

from datetime import datetime, timedelta, timezone

async def recheck_partners():
"""Re-look-up partners that have not been checked recently."""
stale = datetime.now(timezone.utc) - timedelta(days=30)
partners = await db.query(
"SELECT * FROM partners WHERE last_checked < $1", stale
)

for partner in partners:
lookup = lookup_partner(partner.peppol_id)
await db.execute(
"UPDATE partners SET found = $1, last_checked = $2 WHERE id = $3",
lookup["found"], datetime.now(timezone.utc), partner.id,
)
if not lookup["found"]:
await notify_admin(f"Partner {partner.name} no longer resolves on Peppol")
There is no partner status webhook

GoRoute publishes no partner events of any kind โ€” its event catalogue contains none. An earlier revision of this page showed subscribing to a partner status-change event and gave a full payload for it; no such event was ever emitted. If you need to know that a trading partner has left the network, poll the lookup endpoint as shown above.

Tiered due diligenceโ€‹

Planned โ€” not available

Everything in this section describes work GoRoute intends to build. None of it exists today. There is no level parameter on POST /api/v1/partners/verify, there is no credit check, no sanctions screening, and no trust score anywhere in the product. Do not write code against this section, and do not tell a compliance team that GoRoute performs these checks. It is kept on this page so the intended direction stays visible, not because it can be called.

The planned model tiers the depth of the check:

Planned tierIntended to cover
BasicExistence on the Peppol network โ€” which is what participant lookup already does today
StandardBusiness registry and trade register validation
ExtendedCredit check and sanctions screening for high-value or sensitive counterparties

No request shape, parameter name or response field is documented for these tiers, because none has been built and any example would be a guess.

Next stepsโ€‹