Feature Catalogue - phax/peppol-cli GitHub Wiki

Feature Catalogue

Every planned command, by area. All entries are proposed โ€” nothing here is agreed or implemented yet.

Status values: proposed ยท accepted ยท deferred ยท rejected.

Library references were checked against the sources of peppol-commons, peppol-directory-client, phorm-client, ph-nss, ddd-model, ph-diver and ph-commons, and against the specifications listed in Specifications. Where a capability does not exist in the libraries, the Notes column says so explicitly rather than implying reuse.

Contents

Identifiers

Split into three areas โ€” pid, doctypeid, procid โ€” rather than one id area. This follows the structure of the Peppol Policy for use of Identifiers 4.4.0, which gives each type its own policy block (POLICY 1โ€“11 for participants, 16โ€“23b for document types, 24โ€“27 for processes), and more importantly the checks genuinely differ. The decisive one is POLICY 2:

Identifier type Value comparison Scheme comparison Max length (excl. scheme)
Participant case-insensitive case-sensitive 130
Document type case-sensitive case-sensitive 500
Process case-sensitive case-sensitive 200
Transport profile case-sensitive โ€” 50

A single id validate would have to pick one comparison rule or guess from the input, and either way some answers would be wrong. Separate areas make the semantics explicit in the command the user types. Lengths and character sets are POLICY 1; peppol-commons matches them exactly (MAX_PARTICIPANT_VALUE_LENGTH = 4 + 1 + 130, MAX_DOCUMENT_TYPE_VALUE_LENGTH = 500, MAX_PROCESS_VALUE_LENGTH = 200, MAX_IDENTIFIER_SCHEME_LENGTH = 25).

pid โ€” Participant Identifiers

Command sketch Description Status Notes / Libraries
peppol pid validate <id> Validate scheme and value: syntax, length, character set. accepted POLICY 1: 1โ€“130 characters excluding the scheme, and only a-z A-Z 0-9 - . _ ~. POLICY 5: the meta scheme is iso6523-actorid-upis and is case-sensitive, while the value is not. peppol-id: PeppolIdentifierFactory.isParticipantIdentifierSchemeValid / isParticipantIdentifierValueValid; strict and lax factories both exist.
peppol pid normalise <id> Print the canonical, lowercased form. accepted POLICY 2: participant values are case-insensitive โ€” 0088:abc equals 0088:ABc, but 0088:abc is not equal to 0010:abc. IIdentifierFactoryBase.getUnifiedValue. Both equalities are worth asserting in tests, since the second is the one people get wrong.
peppol pid scheme <icd> Look up an ISO 6523 ICD: scheme ID, name, state. accepted POLICY 4: schemes come from the normative Peppol code list โ€” PeppolParticipantIdentifierSchemeManager, backed by EPredefinedParticipantIdentifierScheme. POLICY 6: codes starting with 99 are Peppol extended values, not official ISO 6523, which the output should say rather than implying ISO registration.
State handling active / deprecated / removed. accepted POLICY 4a is precise and stricter than "deprecated is discouraged": deprecated MUST NOT be used for newly exchanged business documents or new SMP registrations, removed MUST NOT be used at all, and codes MUST NOT be reused for a different agency. So the warning text should depend on what the user is doing โ€” validating an existing document is not the same as preparing a new registration.
peppol pid country <id> Derive the country where the ICD allows it. accepted peppol-id-checks: PeppolParticipantCountryHelper (GLN, GS1, EU VAT including the EL and XI prefixes, IBAN). A derivation, not registry data, and the output must say so.
peppol pid check-value <id> Scheme-specific check digits. accepted IdentifierValidator over IParticipantIdentifierValidatorSPI. See the check-digit rows under Library Gaps #1 for the GLN situation.
SPIS-scheme participant identifiers route to spid 0242:โ€ฆ accepted SPIS is participant identifier scheme ICD 0242 (ACTIVE since code list 9.3; SPIDHelper.SPIS_PARTICIPANT_ID_SCHEME). So peppol pid validate 0242:000270 validates the participant envelope under PFUOI and delegates the value to the SPIS checks, rather than applying the generic participant character set to it. The output should say which rule set was applied to the value, so the two areas never appear to disagree.
peppol pid dns <id> The DNS encoding of a participant identifier. accepted POLICY 7: <hash-of-value>.<scheme>.<SML-zone-name>, where the hash is SHA-256 over the lowercased value (e.g. 0088:abc), bytes taken as UTF-8, Base32 per RFC 4648 with trailing = stripped, and <scheme> is the meta scheme added as-is. Overlaps peppol dns hash; pid dns is the identifier-centric view, dns resolve the network-centric one.
Lowercasing uses the en_US locale A genuine implementation trap. accepted POLICY 7 states lowercasing "must be performed according to the en_US locale rules (no special character handling)". A default-locale toLowerCase() under a Turkish locale maps I to ฤฑ and silently produces a different hash โ€” so the wrong SMP, or none. Worth an explicit test that pins the locale.

doctypeid โ€” Document Type Identifiers

Command sketch Description Status Notes / Libraries
peppol doctypeid validate <id> Validate scheme and value. accepted POLICY 16: the scheme MUST be busdox-docid-qns or peppol-doctype-wildcard โ€” both are valid, and both are case-sensitive. POLICY 1: 1โ€“500 characters excluding the scheme. PeppolIdentifierFactory.isDocumentTypeIdentifierSchemeValid / isDocumentTypeIdentifierValueValid.
peppol doctypeid parse <id> Decompose into syntax-specific ID, customization ID and version. accepted POLICY 20: the value pattern is <syntax specific id>##<customization id>::<version>, and for XML the syntax-specific ID is <document element namespace URI>::<document element local name>. In URLs it is prefixed <scheme>::. PeppolDocumentTypeIdentifierParts / PeppolGenericDocumentTypeIdentifierParts.
Customization ID rules differ per scheme accepted POLICY 17. For busdox-docid-qns the customization ID MUST NOT contain * or whitespace (ASCII 9, 10, 11, 12, 13, 32, 133, 160) โ€” a checkable rule worth implementing literally. For peppol-doctype-wildcard it is assembled from Parts with @ as separator, and a trailing wildcard indicator * MAY be present, but only in SMP registrations.
peppol doctypeid wildcard <id> Explain a wildcard identifier and what it matches. accepted Wildcard forms exist only for peppol-doctype-wildcard: urn:peppol:pint:billing-1@jp-1, and for SMP registrations only, urn:peppol:pint:billing-1* or urn:peppol:pint:billing-1@jp-1*. Matching is PeppolWildcardSelector.findPeppolDoctypeWildcardMatch. The command should state plainly that a wildcard value in a document instance is invalid.
Abstract document types A rule that is easy to violate and hard to notice. accepted POLICY 23b: a document type marked abstract in the code list MUST NOT appear in a document instance, and MUST only be used in SMP endpoint registrations with scheme peppol-doctype-wildcard and a mandatory wildcard indicator. Code list v9.7 has exactly 2 abstract entries. So validation has to know the context โ€” the same identifier is legal in one place and illegal in another, which is precisely the kind of distinction a CLI can make and a human reading a code list cannot.
State handling accepted POLICY 23a: deprecated MUST NOT be used for new SMP endpoint registrations, removed MUST NOT be used at all. POLICY 23: all valid values come from the code list โ€” except SMP documents using a wildcard customization ID, which is why an unknown-but-wildcard value is not automatically an error.

procid โ€” Process Identifiers

Command sketch Description Status Notes / Libraries
peppol procid validate <id> Validate scheme and value. accepted POLICY 24: the scheme is cenbii-procid-ubl, always case-sensitive. POLICY 25: values come from the code list and MUST NOT contain whitespace. POLICY 1: 1โ€“200 characters excluding the scheme. POLICY 2: values are case-sensitive. PeppolIdentifierFactory.isProcessIdentifierSchemeValid / isProcessIdentifierValueValid, predefined values via PredefinedProcessIdentifierManager.
State handling accepted POLICY 25a, identical in shape to the document type rule: deprecated not for new SMP endpoint registrations, removed not at all.
A process identifier is a "profile identifier" Terminology help. accepted PFUOI notes a Process Identifier Value is referenced in a Peppol BIS specification as the "profile identifier" โ€” worth saying in the output, because people searching a BIS for "process identifier" will not find it under that name.

spid โ€” Service Provider Identifiers

Its own area, for the same reason the three above are separate: SPIS 1.0.0 is a distinct specification with a distinct rule set. A SPID is structured MainID[-UseCaseID[.Suffix]] rather than scheme:value, is at most 44 characters rather than 130, and is assigned by OpenPeppol rather than by an issuing agency. It also identifies a different kind of thing โ€” a Service Provider (corners 2 and 3), where a participant identifier identifies a sender or receiver (corners 1 and 4).

There is a genuine overlap, and both forms need to work. SPIS is registered as participant identifier scheme ICD 0242, so a SPID can legitimately appear as a participant identifier value:

Command Validates Rule set
peppol spid validate 000270-MLS.001 A SPID as a SPID SPIS 1.0.0
peppol pid validate 0242:000270 A participant identifier whose scheme is SPIS PFUOI for the scheme and envelope, delegating the value to SPIS

So pid keeps its 0242 awareness and routes the value portion through the SPID checks, rather than the two areas duplicating each other.

Command sketch Description Status Notes / Libraries
peppol spid validate <value> Validate a Service Provider ID. accepted SPIDHelper.isValidSPID. Verified conformant to SPIS 1.0.0: REGEX_COMPLETE is character-for-character identical to the specification's canonical regex [0-9]{6}(-[0-9A-Z_]{3,12}(\.[0-9A-Z\-\._~]{3,24})?)?, MAX_LEN_SPID matches the stated maximum of 44, and all ten worked examples from the spec validate. Matching is case-insensitive.
peppol spid parse <value> Decompose into Main ID, Use Case ID and Service Provider Suffix. accepted SPIDHelper.getMainID, getUseCaseID, getServiceProviderSuffix. Two structural rules to surface: a suffix is only valid together with a Use Case ID, and the suffix may itself contain dots (000270-Rprtng_MLS.Japan.123), so it is everything after the first dot. Part-level checks are available individually too โ€” isValidMainID, isValidUseCaseID, isValidServiceProviderSuffix.
peppol spid seat <value> Validate a Seat ID and show its parts. accepted Per SPIS 1.0.0: P + the 2-letter Peppol Authority abbreviation + the 6-digit Main ID (PeppolIdentifierHelper.REGEX_SEAT_ID); SPIDHelper.getMainIDFromSeatID and isValidSeatID. Name the PA abbreviation as such, never as a country โ€” POP000270 is OpenPeppol, not a country. No published list exists. So resolving PSE000055 to "Sweden" is not possible from an authoritative source
today. The CLI should therefore show the raw 2-character prefix and label it as the Peppol Authority, without
guessing a name. A curated list is proposed for peppol-commons โ€” see Library Gaps โ€” which would
let the CLI add the name when one is known while still handling unknown prefixes gracefully.
The Seat ID is not a stable key Worth saying out loud in the output. accepted SPIS 1.0.0 is explicit: if a Service Provider changes the Peppol Authority it holds its agreement with, the Seat ID changes โ€” POP000270 becomes PBE000270 โ€” while the 6-digit Main ID stays the same and is unique across all Service Providers. Anything tracking a provider over time should key off the Main ID, so spid seat must show both and label the Main ID as the stable one.
peppol spid main-id <seat-id-or-spid> Extract just the stable 6-digit Main ID, whichever form was given. accepted Composes getMainIDFromSeatID and getMainID. A small command, but the one most useful in a script โ€” it turns either identifier form into the thing that is safe to store.
Use Case IDs are validated against the code list Not listed from here. accepted spid validate and spid parse check a Use Case ID against EPredefinedSPISUseCaseIdentifier, so an unregistered value is flagged. Listing them is not a spid action โ€” it is peppol codelist list spis-use-cases, one kind among the others, with no special treatment. The trap worth flagging: SPIS 1.0.0 uses Rprtng_MLS throughout its worked examples, but that is illustrative syntax rather than a registered value, so a SPID built from it is syntactically valid and semantically meaningless.
peppol spid from-cert <file> Extract the Seat ID and Main ID from a certificate. accepted The Seat ID is the certificate's Subject CN; SPIDHelper.getSeatIDFromCertificate / getMainIDFromCertificate do the whole job. Kept deliberately alongside peppol cert seat-id, which does the same thing: the two areas serve two ways of arriving at the question โ€” "I have a certificate, what is its Seat ID?" leads to cert, while "I need the Main ID, where does it come from?" leads to spid. Making either an alias of the other would force one group of users to guess which area owns it.
One implementation behind both entry points The condition that makes two commands acceptable. accepted spid from-cert and cert seat-id MUST share one implementation and produce identical output, including the same JSON result shape and the same exit codes. Two commands that can drift apart would be worse than one command in the wrong place, and the same test fixtures must run through both โ€” the pattern already applied to can-receive and cert of-endpoint.

DNS / Discovery

Command sketch Description Status Notes / Libraries
peppol dns hash <participant-id> Print the hashed DNS label for a participant. accepted Now confirmed against SML 1.3.0 ยง3.1.1, which matches the peppol-commons implementation exactly: SHA-256 over the lowercased participant ID, Base32-encoded, with trailing = removed (RFC 6234; POLICY 7 of PFUOI 4.4.0). Not MD5 โ€” documentation saying so refers to a provider removed from the library. Library: AbstractBDXLURLProvider.getHashValueStringRepresentation. The spec supplies a test vector, reproduced and confirmed: 0010:5798000000001 โ†’ XUKHFQABQZIKI3YKVR2FHR4SNFA3PF5VPQ6K4TONV3LMVSY5ARVQ.
peppol dns name <participant-id> Print the complete DNS name, including scheme and SML zone. accepted Spec format: <hash over recipientID>.<schemeID>.<SML domain>. getDNSNameOfParticipant; zone from ESML via the selected network.
peppol dns resolve <participant-id> Walk the whole chain step by step โ€” lowercase, hash, DNS name, NAPTR/U-NAPTR lookup, resolved SMP URL โ€” showing each step's input, output and timing, and stopping at the first failure with a clear statement of which step failed. accepted getSMPURIOfParticipant; U-NAPTR resolution via ph-dns (NaptrLookup, NaptrResolver) and dnsjava. The provider exposes setUseNaptrDebug, customDNSServers() and setUseDNSCache, which map naturally onto --verbose, --dns-server and --no-cache. The lookup is bounded by --dns-timeout (default 5s), not by the HTTP timeouts; since the resolved SMP is then queried over HTTP, one peppol smp command can involve all three timeouts, so timing each step separately is what makes a slow lookup attributable. This step-by-step output is the single most valuable diagnostic in the tool.
peppol dns resolve --check-uri-rules Validate the resolved SMP URL against the rules SML 1.3.0 ยง2.1.1 places on U-NAPTR URI values, and report each violation. accepted Verified, and pleasingly checkable: the URI MUST use only the https scheme, MUST NOT carry username/password in the authority, and MUST NOT include query or fragment parts. Scheme and host are case-insensitive, everything else case-sensitive (RFC 3986). A misconfigured SMP registration that still "works" for some clients is exactly the kind of latent fault a diagnostic tool should surface. Combine with the SMP spec's port-443 requirement for a complete conformance check of the registration.
NAPTR service name filtering Select the right record among several. accepted Spec: the service name MUST be Meta:SMP and no other name may be used for this purpose, but a participant's NAPTR query may legitimately return additional records with other service names for other purposes. So the CLI filters by service name and, under --verbose, should show the records it discarded โ€” a participant with unexpected extra NAPTR records is worth seeing. The field is case-insensitive per RFC 4848. peppol-commons has DNS_UNAPTR_SERVICE_NAME_META_SMP = "Meta:SMP".
peppol dns resolve --dnssec Validate the resolution chain with DNSSEC and report the outcome. accepted ph-dns provides a com.helger.dns.dnssec package (@since 11.4.7) and peppol-commons exposes it as AbstractBDXLURLProvider.setDnsSecValidation (boolean) / isDnsSecValidation () (@since 13.1.2), which feeds NaptrLookup's dnsSecValidation (...) builder option. So --dnssec is a thin flag over a library capability rather than new code. Motivated by SML 1.3.0 ยง5 "DNS Spoof Mitigation", which recommends DNSSEC for exactly the rogue-certificate-plus-DNS-poisoning scenario.
The result is four-valued, not a boolean This is the part to get right in the output. accepted NaptrLookupResult.getDnsSecStatus () returns an EDnsSecValidationStatus: NOT_VALIDATED (not enabled, or no response at all), SECURE (validated along the chain of trust), INSECURE (provably unsigned โ€” the zone is not signed โ€” but nothing bogus), BOGUS (validation failed: bad, missing or expired signatures, or a broken chain). Convenience predicates isSecure () and isBogus () exist. Collapsing this to pass/fail would be actively misleading: INSECURE and BOGUS mean opposite things about whether someone is interfering.
The Peppol SML zones are signed โ€” so SECURE is the expected result accepted Confirmed, and independently verified by DNS query: DS records are present at every level of the delegation chain (peppol.org โ†’ tech.peppol.org โ†’ prod.tech.peppol.org โ†’ sml.prod.tech.peppol.org), algorithm 13 (ECDSAP256SHA256), and the test zone sml.test.tech.peppol.org is signed too. A query through a validating resolver returns the ad (authenticated data) flag with RRSIG records present. So DNSSEC validation genuinely works end to end on both networks, and --dnssec delivers real assurance rather than a no-op.
Which makes INSECURE suspicious, not routine accepted INSECURE means provably unsigned. Against a zone that is known to be signed with a proper DS delegation, that should not occur โ€” so seeing it points at something interfering with the chain: a resolver stripping DNSSEC records, a middlebox, or a downgrade attempt. It is still not a DNSSEC validation failure in the protocol sense, so it does not warrant the same exit code as BOGUS, but the output must treat it as an anomaly worth investigating rather than as a benign "this zone isn't signed". Quieter than BOGUS, and for that reason easier to miss.
A validated NXDOMAIN is a stronger answer An underrated benefit. accepted Because the zones are signed, a "no such participant" response is itself authenticated โ€” the observed responses carry RRSIG and NSEC records proving non-existence rather than merely failing to answer. With --dnssec, exit code 3 (not found) therefore means provably not registered, not just nothing came back. That is a meaningfully better answer for can-receive, and worth saying in the output, since the distinction between "not registered" and "lookup did not work" is exactly what users struggle with.
BOGUS is a security finding โ€” exit 4 accepted Broken or expired signatures on a signed zone are what DNS spoofing looks like. Exit code 4, not 5: the response was received and failed validation, which is a check failure rather than a transport failure, and it keeps 5 meaning "the lookup could not complete". peppol-commons already treats it as fatal โ€” AbstractBDXLURLProvider throws SMPDNSResolutionException with EErrorCode.DNSSEC_VALIDATION_FAILED, carrying the status and message, and ENaptrLookupStatus.DNSSEC_VALIDATION_FAILED / isDnsSecValidationFailed () mark the lookup. The CLI should surface this prominently rather than as one line among many.
Exit 4 does not identify the cause Which is why the message has to. accepted A resolver that strips DNSSEC records also produces a validation failure, so it exits 4 as well โ€” the same code as genuine tampering. The exit code states that a check failed; only the message can say whether the likely cause is the user's resolver or something interfering with the chain. That is the reason the resolver diagnosis above is a requirement rather than a nicety: without it, the two most different situations this command can encounter are indistinguishable.
Enabling --dnssec can break every lookup The caveat that matters most in practice. accepted From the peppol-commons 13.1.2 release note: "The used DNS servers must forward the DNSSEC records - otherwise every lookup fails." So a user who enables --dnssec behind a resolver that strips DNSSEC data sees every participant fail, which looks exactly like a network-wide outage or a tool bug. The CLI must recognise the pattern โ€” DNSSEC enabled, and all lookups failing validation โ€” and say so: the resolver is the likely cause, and --dns-server with a DNSSEC-capable resolver is the fix. This is precisely the class of misdiagnosis the tool exists to prevent.
Default ON โ€” a deliberate divergence from the library --dnssec / --no-dnssec accepted AbstractBDXLURLProvider.DEFAULT_DNSSEC_VALIDATION is false, and this tool overrides it to true. See ADR-009. The justification is specific rather than general: both SML zones are verifiably signed, so validation works today; the threat DNSSEC addresses โ€” DNS poisoning combined with a rogue certificate โ€” is the one SML 1.3.0 ยง5 singles out; and a diagnostic tool that quietly accepts unvalidated answers is giving a weaker answer than it could. --no-dnssec is the escape hatch.
No silent fallback accepted If validation fails, the command fails. It does not retry without DNSSEC and it does not downgrade automatically, because a silent downgrade is precisely the attack DNSSEC exists to prevent โ€” a tool that falls back on failure provides no protection at all while appearing to. Continuing requires the user to pass --no-dnssec explicitly, which makes the decision theirs and visible in their shell history or CI config.
The resolver problem becomes a first-run experience Now essential, not nice-to-have. accepted With the default on, every user behind a resolver that does not forward DNSSEC records hits total failure on their first command. So the diagnosis has to be excellent: detect the pattern (DNSSEC enabled, validation failing for everything, including names known to resolve), and say plainly that the resolver is at fault โ€” naming the resolver in use โ€” with the two fixes side by side: --dns-server <a DNSSEC-capable resolver> to fix it properly, or --no-dnssec to proceed without validation. A bare "lookup failed" here would make the tool look broken to a user who has done nothing wrong.
peppol config validate checks the resolver โ€” on demand only accepted A single query through the configured resolver establishes whether authenticated data comes back, so config validate and config show can report "DNSSEC: supported by your resolver" or "not supported โ€” expect --no-dnssec to be needed". It runs only when asked, never automatically on first use, and the answer is not cached. Two reasons: an unprompted network request on someone's first command contradicts the rule applied everywhere else in this tool, and resolver capability is network-dependent โ€” a cached verdict would be wrong exactly when it matters most, on the laptop that moved from the office to a home VPN. The check is cheap enough that running it on demand costs nothing.
So the failure message must name the check The cost of on-demand. accepted Because the resolver problem surfaces on the user's first real command rather than being caught beforehand, the error has to be self-sufficient: name the resolver as the likely cause, offer --dns-server and --no-dnssec, and point at peppol config validate as the way to confirm. Without that last part, "run the diagnostic first" is advice nobody receives until after they have already hit the wall.
DNS caching: keep the key cache, drop the rest accepted The two caches have very different value for a short-lived process, and measured TTLs show why. Per-participant records carry TTL 60 (the SML zone's NAPTR/SOA/NSEC records), are unique to each participant, and are useless to cache beyond one lookup. The DNSSEC trust-chain keys carry TTLs of roughly 36โ€“60 minutes โ€” 3600 on sml.prod.tech.peppol.org, 2162 on peppol.org, 2482 on org, 2668 on the root โ€” and are identical for every participant. So DnsSecHelper.DEFAULT_KEY_CACHE_MAX_TTL of one hour is well matched to reality, and the key cache should stay enabled.
The key cache earns its place in bulk, not single-shot accepted For peppol dns resolve <one participant> the cache saves nothing โ€” the process exits. For peppol bulk over a CSV it is the difference between validating the chain once and validating it per row: roughly six DS/DNSKEY fetches in total instead of six per participant. At a thousand rows that is thousands of avoidable queries against the same records. The value is therefore intra-process and proportional to the number of lookups in one command, which is exactly the shape the library default already assumes.
Do not build a cross-invocation cache accepted A cache on disk is the obvious idea and the wrong one. The cross-process caching that matters already exists: the OS stub resolver honours those TTLs, so two invocations within a minute hit the system cache without this tool doing anything. Adding a persistent DNS cache on top would mean serving SMP URLs past their TTL โ€” the exact staleness this tool exists to diagnose โ€” and a persistent DNSSEC trust anchor would be a security liability rather than an optimisation.
--no-cache scoped honestly accepted Given the above, --no-cache must be documented for what it actually affects rather than implying it clears something durable: it bypasses the revocation result cache (6 hours, which genuinely can return a stale verdict โ€” see Certificates), disables the in-process DNS cache via setUseDNSCache, and bypasses the DNSSEC key cache. It does not and cannot clear the operating system's resolver cache, which is the one most likely to be serving a stale answer. Saying so prevents a support conversation that would otherwise go in circles.
If per-invocation cost ever becomes the problem accepted A resident background process would make every cache worthwhile, but it is a different tool: this one is deliberately short-lived, stateless and without a listening port (ADR-001). The cost it would address is startup and chain validation per invocation, which is the same ground as the native image question in Open Questions #1 โ€” so if that is ever measured and found wanting, the two should be considered together rather than a daemon being bolted on for caching alone.
peppol dns wait <participant-id> Poll until the record resolves, for use after an SML write. accepted Plain polling around dns resolve; see the waiter convention in CLI Conventions.
--network test|prod on all of the above Select the SML zone. accepted EPeppolNetwork โ†’ ESML. No zone literals in the CLI's own code.

SML

Grounded in Peppol SML 1.3.0 (see Specifications). One point from the spec shapes this whole area:

There is no anonymous SML read interface. Both management interfaces require authentication, and ยง4.2.2/ยง4.3.2 mandate two-way TLS: the caller authenticates with a client certificate, and the server "must reject TLS clients that do not authenticate with a certificate issued under the Peppol root CA". The authenticated identity determines which SMP's data the caller may touch.

So there are two distinct classes of command: things any user can do against DNS, and things only an SMP operator can do against their own SML records. Conflating them would be misleading, so the CLI should not. Looking up a third party's registration through the SML is therefore not possible and is listed under Rejected features; use peppol dns resolve instead.

Available to an authenticated SMP operator

Requires the configured SMP client certificate. Operations are SOAP 1.1 over HTTP (WSDLs ship with the spec), conforming to WS-I BP 1.1 and BSP 1.0.

Command sketch Description Status Notes / Libraries
peppol sml smp read <smp-id> Read your own SMP record: the publisher endpoint (domain name or IP) and the SMP ID. accepted ManageServiceMetadata::Read(). Input ServiceMetadataPublisherID, output ServiceMetadataPublisherService. peppol-sml-client: ManageServiceMetadataServiceCaller.
peppol sml smp create / update Create or update the SMP record, including its logical and physical address. accepted ManageServiceMetadata::Create() / Update(). The ServiceMetadataPublisherID is assigned out of band, so it is configuration, not something the CLI can discover.
peppol sml smp delete <smp-id> Delete the SMP record. accepted ManageServiceMetadata::Delete(). Highest-impact SML operation โ€” it detaches every participant of that SMP. Confirmation plus blast-radius output mandatory.
peppol sml participant add <id> Register one participant against your SMP. accepted ManageBusinessIdentifier::Create(). Input is ServiceMetadataPublisherServiceForParticipant โ€” the participant identifier plus the SMP ID. Note the spec: exactly one SML record per participant regardless of how many services it exposes.
peppol sml participant add --from <file.csv> Register many participants in one call. accepted CreateList() exists โ€” input is a ParticipantIdentifierPage with NextPageIdentifier absent. So batch registration is a first-class spec operation, not a client-side loop.
peppol sml participant remove <id> Remove one participant registration. accepted Delete().
peppol sml participant remove --from <file.csv> Remove many participants in one call. accepted DeleteList(). Mimic the API's own semantics, because it gives nothing else to work with: ManageParticipantIdentifierServiceCaller.deleteList (...) returns void and signals only through faults โ€” BadRequestFault, InternalErrorFault, NotFoundFault, UnauthorizedFault. Success therefore means "no fault was thrown" and nothing more; there is no per-identifier result. Same for createList (...), which is also void.
The batch gives no per-row outcome So do not invent one. accepted SML 1.3.0 returns notFoundFault if one or more of the supplied identifiers could not be found, and does not say which. So the CLI must report exactly that โ€” "the batch failed with notFoundFault; the SML does not identify which of the N identifiers were unknown" โ€” rather than a per-row table it cannot populate. Fabricating per-row status from a single void call would be the most misleading thing this command could print, since a reconciliation job would then trust rows that were never individually confirmed. Exit 3 for notFoundFault, per the fault mapping.
Need structured per-identifier outcomes? Use bulk Not a flag on this command. accepted For the common case the batch fault message already names the unknown identifiers (above), so no extra mechanism is needed to answer "which ones?". When structured, per-row results are genuinely required โ€” feeding a reconciliation report rather than reading an error โ€” that is what the Bulk area does: N individual calls, one result row each, --continue-on-error, progress, and CSV or JSON Lines output. Reusing it avoids a bespoke flag for something the area already covers.
The division of labour is the point accepted Two forms, and which one you type states what you are asking for: sml participant remove --from <csv> is the API's own batch call โ€” one request, cheapest against someone else's infrastructure, and no per-row detail because the API has none to give. peppol bulk sml-participant-remove <csv> is N single-identifier calls โ€” per-row truth, at N times the cost. The trade-off is visible in the command rather than buried in a flag, which is the better place for it. The same split applies to sml participant add and its CreateList() batch.
A partially-bad batch deletes nothing Confirmed in the BDMSL source. accepted SML 1.3.0 leaves this open, but the implementation does not: ManageParticipantIdentifierBusinessImpl.deleteParticipantList compares the found participants against the requested ones and throws before the deletion loop is reached, so no participant is removed. The whole service method is @Transactional (rollbackFor = Exception.class), so a failure during the subsequent DNS update rolls the database work back too. All validation happens up front, in order: list non-empty, SMP exists, caller authorised, every participant's SMP ID matches the target publisher (else BadRequestException), the SMP owns every participant (else UnauthorizedException plus a security log entry), and no migration is planned for any of them. So after a notFoundFault the state is unchanged, and the CLI can say so rather than hedging.
And the fault message names the missing identifiers Better than the interface promises. accepted Before throwing, BDMSL removes the existing participants from the list and puts the remainder into the message. So although the SML interface contract says the fault does not identify which identifiers were unknown, in practice the message does. The CLI MUST therefore surface the fault message verbatim โ€” which answers "which ones?" for the common case without any extra calls. It MUST NOT parse that message: it is human-readable text that could be reworded in any release, and logic built on it would break silently. For structured per-row results, peppol bulk remains the route.
peppol sml participant list Fetch one page of participants registered against your SMP. accepted Mirrors the List() operation one-to-one: input PageRequest (SMP ID plus optional NextPageIdentifier), output ParticipantIdentifierPage with the next page identifier if any. Prints the NextPageIdentifier so a caller can drive the paging itself, which is what makes this the right command for debugging the raw API or scripting a custom traversal.
peppol sml participant listall List every participant registered against your SMP. accepted The iteration is already in the library: ManageParticipantIdentifierServiceCaller.listAllPages (String sSMPID, Function <? super ParticipantIdentifierPageType, EContinue>) (@since 13.1.0) walks the pages and returns the page count, so the CLI writes no paging loop here either. It also guards against an SML that keeps referencing an already-read page โ€” it tracks handled page IDs to avoid an endless loop โ€” which is defensive work worth not duplicating. The handler returns EContinue, so --max-items is a BREAK and output streams per page.
listall is not an atomic snapshot The caveat that matters, and it only applies here. accepted SML 1.3.0 ยง3.1.2.7 is explicit: "the underlying data may be updated between one invocation of List() and a subsequent invocation of List(), so that a set of retrieved pages of participant identifiers may not represent a consistent set of data." A participant registered or removed mid-traversal can therefore be missed or appear twice. Since the obvious use of listall is reconciling the SML against an SMP database, presenting the result as a clean snapshot would produce false reconciliation differences. The output must say it is a best-effort traversal, and --output json should carry that as a field rather than only as prose.
Duplicate detection as a concurrency signal Default behaviour. accepted Because the data can shift mid-traversal, a participant appearing in two pages is evidence that it happened. By default listall does not de-duplicate: it reports duplicates explicitly, since "the registration set changed while I was reading it" is exactly what a reconciliation job needs to know before trusting its diff.
--dedupe Optional, off by default. accepted Decided: add it. For callers who just want the set โ€” feeding a database, counting registrations โ€” --dedupe collapses repeats so the output is a clean list. Crucially it must still report how many duplicates were collapsed, in the text summary and as a count in the JSON envelope: suppressing them from the listing is a convenience, suppressing the knowledge that they occurred would throw away the only evidence that the traversal was not consistent. So --dedupe changes the output, never the diagnosis.
Progress and streaming A real SMP can hold tens of thousands of participants. accepted Emit results as they arrive rather than buffering the whole set, report progress on stderr (pages fetched, participants so far) so a long traversal does not look hung, and finish with a count. In --output json a single document holding tens of thousands of entries is awkward, which is exactly what --output jsonl is for โ€” decided for peppol bulk and applying here unchanged, header line, one line per participant, trailer line.
A deliberate exception to the --all convention accepted Decided: listall stays a separate action, and list --all is not offered. CLI Conventions defines --all as "follow all pages" for result sets that differ only in length, which is not this case: listall makes potentially thousands of authenticated SOAP calls instead of one, carries a non-atomicity caveat that the single-page form does not, and is the one command here that a user should never trigger by accident. A distinct verb makes the cost visible in what they type. The exception is recorded in CLI Conventions so the two pages do not appear to disagree.
peppol sml migrate prepare <id> Step 1 of a migration, run on the old SMP: generate and register a migration key. accepted PrepareToMigrate(), input MigrationRecord (SMP ID, participant identifier, migration key). Only the SMP currently publishing that participant may call it. The key then travels to the new SMP out of band โ€” the spec explicitly defines no interface for that.
peppol sml migrate complete <id> --key <key> Step 2, run on the new SMP: take over, which repoints DNS. accepted Migrate(). PrepareToMigrate() must have been called with the same key or this fails. After success the participant resolves to the new SMP.
peppol sml migrate key Generate a migration key that satisfies the rules. accepted Verified spec rules (ยง2.2): 8โ€“24 characters; at least 2 lowercase, 2 uppercase and 2 digits; at least 2 from `@ # $ % ( ) [ ] { } * ^ - ! ~
peppol sml cert-change <action> โ€” a BDMSL extension, not SML 1.3.0 Certificate rollover for your own SMP. accepted Decided: supported, and labelled as an extension everywhere it appears. SML 1.3.0 defines no certificate-change operation, but BDMSL exposes one on the operator-facing BDMSLService โ€” and every SMP certificate expires, which makes rollover an ordinary operational task rather than an edge case. manageChangeCertificate takes an Action (UPSERT, READ, DELETE, ACTIVATE), an optional Certificate (X.509 as base64 or PEM) and an optional MigrationDate (xs:dateTime), and returns a single Status string. The caller is identified by the certificate it authenticates with, so an operator can roll their own certificate without involving the SML operator. Note the XSD's own annotation lists only "UPSERT, READ, DELETE" as possible values and then documents ACTIVATE three lines further down, so the four-value set comes from reading the whole annotation rather than its summary line.
Target manageChangeCertificate, not prepareChangeCertificate The older operation is going away. accepted prepareChangeCertificate is @Deprecated (since = "03/03/2026", forRemoval = true) in BDMSL, superseded by the generic manageChangeCertificate. Building on the deprecated one would be building on something with an announced removal date.
Say "BDMSL extension" in the output, not just in the docs The condition the decision rests on. accepted The reason this was initially rejected still holds in part: presenting an implementation extension as though it were a standard operation misleads the user about what is portable. So the command's help text and its output name it as a BDMSL extension, and --output json carries that as a field rather than only as prose. An operator who later moves to a non-BDMSL SML implementation should be able to see from the output which of their automation was relying on something outside the specification.
Other non-spec BDMSL operations โ€” not adopted Where the line is drawn. accepted The same service offers existsParticipantIdentifier (a cheaper existence check than a full lookup) and refreshSmpActiveStatus; neither is adopted, because each duplicates something the CLI already does through standard operations, and an extension earns its place only when there is no specified way to do the job. BDMSLAdminService is SML-operator administration (subdomains, truststore certificates, DNS records, properties, clearCache, report generation) and is out of scope for a tool aimed at SMP operators; BDMSLMonitoringService offers only isAlive. There is no SMP listing operation anywhere in the non-spec services.
Fault-to-exit-code mapping accepted The spec defines exactly four faults, which map cleanly: notFoundFault โ†’ 3, unauthorizedFault โ†’ 7, badRequestFault โ†’ 2, internalErrorFault โ†’ 5. All four share the action URI http://busdox.org/2010/02/locator/fault and SOAP Code = Sender. This is a rare case where the exit-code mapping is fully determined by the spec.

SMP โ€” read

Grounded in Peppol SMP 1.4.0. The spec defines exactly two GET resources and nothing else:

Resource URL Returns
ServiceGroup /{participantId} <ServiceGroup> โ€” the participant plus references to its ServiceMetadata resources
SignedServiceMetadata /{participantId}/services/{docTypeId} <SignedServiceMetadata>, or a Redirect

Verified binding rules the CLI must respect and can also check:

  • TLS is mandatory and the service MUST be operated on port 443 (new in 1.4.0). An SMP reachable only on another port, or over plain HTTP, is non-conformant โ€” which makes this a reportable finding, not just a connection detail.
  • Content-Type MUST be text/xml or application/xml; the body MUST be UTF-8 with an XML declaration whose encoding is UTF-8 (value not case-sensitive).
  • HTTP status codes are fixed: 200 retrieved, 404 resource not found, 500 internal error. The service SHOULD NOT use 3xx redirection and clients need not follow it. So 404 โ†’ exit 3 and 500 โ†’ exit 5, directly.
  • Identifiers are percent-encoded per path segment (RFC 3986); the delimiting slashes are not encoded. Participant: {meta scheme}::{scheme}:{value}, e.g. iso6523-actorid-upis::0010:5798000000001. Document type: {scheme}::{value}. The base URL from the NAPTR lookup may carry a constant path prefix.
Command sketch Description Status Notes / Libraries
peppol smp service-group <participant-id> List the participant's service group: every document type it declares. accepted peppol-smp-client: SMPClientReadOnly.getServiceGroup / getAllDocumentTypes.
peppol smp metadata <participant-id> <doctype-id> Fetch the service metadata and show every field the spec defines per endpoint. accepted SMPClientReadOnly.getServiceMetadata; getEndpoint, getEndpointAt, isEndpointValidAt, getEndpointAddress, getEndpointCertificate. Spec fields worth surfacing, not just the obvious ones: EndpointReference (a WS-Addressing EPR), @transportProfile (e.g. peppol-transport-as4-v2_0), RequireBusinessLevelSignature, MinimumAuthenticationLevel, ServiceActivationDate, ServiceExpirationDate, Certificate (PEM), ServiceDescription, TechnicalContactUrl, TechnicalInformationUrl. The last two are exactly what a support engineer wants and no other tool shows conveniently.
Activation and expiry are normative, not informational An endpoint outside its validity window must be treated as unusable. accepted Spec: "Senders MUST ignore services that are not yet activated" and "MUST ignore services that are expired"; a missing activation date means "valid since forever" and a missing expiration date "valid until eternity". Both are xs:dateTime. So can-receive must exclude out-of-window endpoints and say why โ€” reporting an expired endpoint as usable would be a correctness bug. isEndpointValidAt exists for this, and takes an explicit date, which is also what makes it testable. This is what --at on can-receive exposes as a parameter.
peppol smp verify-signature <participant-id> <doctype-id> Verify the SMP response's XML signature, report the signing certificate, and check the signature conforms to the mandated profile โ€” an enveloped signature with exactly one enveloped-signature transform, KeyInfo/X509Data/X509Certificate present, inclusive c14n (REC-xml-c14n-20010315), rsa-sha256 signature method and sha256 digest method. A signature that verifies but uses a non-conformant algorithm is a finding worth reporting. accepted Both arguments are mandatory. Only the SignedServiceMetadata resource at /{participantId}/services/{docTypeId} carries a signature โ€” SMP 1.4.0 ยง5.5.1 places the ds:Signature inside the SignedServiceMetadata element, and the resource name says as much. There is nothing to verify for a participant alone. Libraries: smp-client-base SMPHttpResponseHandlerSigned (verification on by default, with secure validation) and TrustStoreBasedX509KeySelector, which also supports revocation checking via ERevocationCheckMode. Trust store from PeppolTrustStores.Config2025 SMP entries.
The service group listing is not signed A widely-assumed-signed response that is not. accepted The ServiceGroup resource at /{participantId} โ€” the list of document types a participant supports โ€” carries no XML signature. Confirmed in the spec (only SignedServiceMetadata is signed) and in the library: SMPClientReadOnly.getServiceGroup uses SMPHttpResponseHandlerUnsigned, while the metadata methods use SMPHttpResponseHandlerSigned. So peppol smp service-group output is protected by TLS in transit and by nothing at rest, and the tool should say so plainly rather than letting "SMP responses are signed" be assumed uniformly.
Which parts of an answer are authenticated The distinction a diagnostic tool should make visible. accepted peppol smp can-receive <pid> <doctype> goes straight to the signed resource, so its endpoint, transport profile and certificate come from a signed document and can be authenticated. peppol smp lookup <pid> and peppol smp service-group <pid> enumerate from the unsigned listing. Worth labelling per-command, and in the JSON envelope, so a user can tell which parts of what they are reading are cryptographically backed. With DNSSEC on by default the discovery step is authenticated too โ€” which makes the unsigned listing the one remaining soft spot in an otherwise verifiable chain โ€” for as long as it stays unsigned, since a future specification version may change that.
Signing the service group is under discussion And the design already absorbs it. accepted A future SMP specification version may sign the ServiceGroup resource. That should require no change here, because the verification path is driven by whether a ds:Signature is present, not by which resource was requested โ€” so a signed listing would be verified automatically the day it appears, and an unsigned one keeps being reported as unauthenticated. The only thing that would then need revisiting is the wording that currently calls the listing the one soft spot in the chain. Designing to the resource type instead would have meant a code change plus a release to pick up a server-side improvement.
peppol smp can-receive <participant-id> <doctype-id> [<process-id>] [--transport-profile <id>] [--at <date>] The flagship command. One question, one answer: can this participant receive this document, and if so at which endpoint URL, over which transport profile, with which certificate? Reports the failing step when the answer is no. accepted Composes dns resolve + getServiceMetadata + endpoint selection + certificate check. Must handle wildcard document types โ€” PeppolWildcardSelector.findPeppolDoctypeWildcardMatch exists for exactly this and is essential for PINT correctness. Exit code 3 when not registered, 4 when registered but unable to receive.
--transport-profile <id> Which transport profile the question is about. accepted An endpoint may publish several profiles, so "can X receive Y" is only fully answered against one. Defaults to the Peppol AS4 profile โ€” peppol-transport-as4-v2_0, the single active entry in code list v9.7 โ€” rather than being mandatory, because that is the answer almost everyone wants. A flag rather than a fourth positional argument: it has a sensible default, and four positionals would be unreadable. If the participant supports the document type but not over the requested profile, that is its own answer โ€” exit 4 with "registered, supports this document type, but not over <profile>; it publishes <other profiles>" โ€” not a bare "cannot receive".
--at <date> Evaluate against a specific point in time. accepted Optional, defaulting to now. SMP 1.4.0 makes the endpoint validity window normative: senders MUST ignore services that are not yet activated or have expired, with a missing ServiceActivationDate meaning "valid since forever" and a missing ServiceExpirationDate "valid until eternity". isEndpointValidAt in peppol-commons takes an explicit date, so this is a parameter rather than new logic. Two concrete uses: diagnosing a past failure โ€” "could this participant have received on 2026-03-14?" answers a question no other tool answers โ€” and planning ahead, catching an endpoint that expires next month before it breaks.
The evaluation date is always echoed accepted Whether defaulted or supplied, the date used appears in the output and as a field in JSON. A stored result saying "can receive" is meaningless without the instant it was true at, and a --at answer that does not state its date is worse than no answer. Same reasoning as echoing a resolved alias.
peppol smp lookup <participant-id> Convenience: resolve the SMP, then list everything found, in one call. accepted Composition of the above.
--no-follow-redirects Do not follow an SMP redirect to a secondary SMP. accepted Following is the default, since a redirect is a legitimate part of the lookup โ€” SMP 1.4.0 ยง2.2 exists so a participant can use different SMPs for different document types, and a client that refused to follow would report a false negative. --no-follow-redirects is for inspecting what one specific SMP itself publishes. Spec rules: only one degree of redirect is allowed โ€” a redirect resource cannot point to another redirect โ€” so the CLI follows at most one hop and reports a longer chain as a malformed-SMP finding rather than following it. A followed redirect changes which SMP answered, so the output must always say so. Library: smp-client-base ISMPFollowRedirectCallback.
Redirect certificate check Verify the destination SMP is the one the redirecting SMP vouched for. accepted The field is Redirect/CertificateUID (xs:string, mandatory), alongside Redirect/@href for the destination URL. Its schema documentation states a client "SHOULD validate that the Subject Unique Identifier of the certificate used to sign the resource at the destination SMP matches the Subject Unique Identifier published in the redirecting SMP". A SHOULD, so clients commonly skip it โ€” which makes it exactly the check a diagnostic tool should perform and report.
What CertificateUID actually contains The schema wording is misleading. accepted It was agreed to use the X.509 Certificate Subject X.500 Name โ€” the subject DN โ€” as the identifier. The schema's phrase "Subject Unique Identifier" reads like the X.509 subjectUniqueID field, which is a different thing entirely and is effectively unused (deprecated since X.509v2, and not present in Peppol certificates). Implementing the documentation literally would therefore compare against a field that is never populated, and the check would silently always pass or always fail. Compare the destination signing certificate's subject DN instead.
Compare DNs canonically, not as strings A trap worth avoiding. accepted Distinguished names have several textual renderings โ€” attribute ordering, spacing, escaping, RFC 1779 versus RFC 2253 versus a JDK getName () default โ€” so raw string equality will report a mismatch between two renderings of the same DN. Normalise both sides first, e.g. X500Principal.getName (X500Principal.CANONICAL), and when they differ show both values so the operator can see whether it is a genuine mismatch or a formatting artefact. A false "the destination SMP is not the one vouched for" would be alarming and wrong.
JSON output for all of the above accepted peppol-smp-client: SMPJsonResponse already renders SMP query results to JSON with stable field-name constants. Strong candidate for the result payload, so the CLI does not invent a second SMP JSON shape.

phoss-SMP โ€” write

Important finding from SMP 1.4.0 ยง3: the specification "defines a REST-based interface for retrieving Service Metadata, but does not specify interfaces for creating, updating, deleting and managing Service Metadata, or any internal data storage formats." The stated goal is to let SMPs expose data from any back end.

So there is no standard Peppol SMP write API. What peppol-smp-client's SMPClient speaks (saveServiceGroup, saveServiceInformation, saveServiceRedirect, deleteServiceGroup, deleteServiceRegistration, with BasicAuthClientCredentials) is in practice the phoss-SMP write API, not a normative interface.

Decision: these commands live under their own phoss-smp area rather than under smp. The area name states what the commands actually talk to, so nobody mistakes them for standardised Peppol operations, and the split is visible at a glance: peppol smp โ€ฆ is the specified read interface that works against any conformant SMP, while peppol phoss-smp โ€ฆ is implementation-specific administration. It also leaves room for a future peppol <other>-smp area should another implementation's API ever be worth supporting, without renaming anything.

Spelling: both work. phoss-smp is the canonical form โ€” hyphenated, consistent with the hyphenated action names, and the one shown in help and shell completion โ€” and phosssmp is an accepted alias for it. The triple s is easy to mistype in either direction, so accepting both costs nothing and spares the user a failed command over a hyphen. They dispatch to the same commands, so they cannot drift apart.

Consequences that must be stated in the CLI's own documentation:

  • Write commands are implementation-specific by construction, and the area name says so. They are developed and tested against phoss-SMP. Other SMP implementations may happen to expose the same API โ€” several do โ€” and are then likely to work, but that is not a promise the tool makes.
  • A write failure may mean "your SMP does not support this API", which is a different diagnosis from bad credentials or bad data. That deserves its own error message.
  • Anything relying on this convention is at risk if an implementation changes it, since no spec pins it.
  • The SMP spec does note (ยง3) that adding or removing participants in an SMP requires reflecting custody in the
  • The SMP owns its downstream registrations. SMP 1.4.0 ยง3 notes that "when adding or deleting Participant Identifiers in the SMP, an implementation of the SMP will need to reflect its custody of a Participant Identifier in the SML" โ€” the responsibility is the SMP's, not the client's. So peppol phoss-smp create-participant is sufficient on its own: an SMP with an SML connection configured performs the SML registration itself. This holds for every writing SMP operation, and the CLI MUST NOT attempt the SML side as well โ€” doing so would duplicate work the SMP has already done, and at best fail redundantly. A combined command is the genuinely useful one, and the CLI should offer it rather than leaving users to remember the second step.

Preflight via /smp-status. phoss SMP exposes a status endpoint โ€” SMPStatusServlet.SERVLET_DEFAULT_NAME = "smp-status", mapped as /smp-status/* in all three webapp variants โ€” returning JSON. It answers the compatibility question before any write is attempted:

Field Use
smp.writable-rest-api.enabled The field that actually matters. Set from !isRESTWritableAPIDisabled (), so it answers "will writes work here?" directly. A phoss SMP with the writable API switched off would pass any name check and still reject every write.
smp.application Identifies the implementation. Check with startsWith ("phoss SMP"), not equality โ€” the value is CSMP.getApplicationTitle (), which is APPLICATION_TITLE ("phoss SMP") plus getApplicationSuffix (), so an exact match is fragile.
smp.resttype The REST flavour in use, from SMPServerConfiguration.getRESTType ().
smp.mode test or production. Cross-check against --network โ€” a mismatch here is a mistake worth catching before a write, and more so now that --network defaults to prod.
smp.id The SML SMP ID (getSMLSMPID ()), which the CLI also needs as configuration โ€” so the preflight can verify the two agree instead of letting them silently diverge.
smp.sml.enabled, smp.sml.needed Whether this SMP maintains its own SML connection (isSMLEnabled () / isSMLRequired ()). This is the field that explains the most common confusing state: a participant present in the SMP but not resolvable through DNS, because the SMP has no SML connection and therefore never registered it.
smp.sml.url, smp.sml.dnszone Which SML the SMP talks to โ€” cross-checkable against the CLI's own --network, so a prod SMP pointed at the test SML is visible before anything is written.
smp.pd.enabled, smp.pd.needed, smp.pd.auto-update, smp.pd.hostname The same pattern for Directory integration, and the reason the Directory indexing commands need the caveat below.
smp.certificate.configuration-valid, .issuer, .subject, .expired, .notbefore, .notafter The SMP's own certificate state, reported by the SMP itself. A cheap second opinion alongside peppol cert verify, and the quickest explanation for an SMP whose signatures have started failing.
version.smp, smp.backend, smp.publicurl Worth echoing in --verbose and in bug reports.

Caveat: the endpoint can be switched off (smp.status.enabled, checked by SMPStatusXServletHandler; a disabled instance reports status.enabled: false). So absence of a usable status response is inconclusive, not a negative โ€” the CLI must proceed and let the write attempt speak for itself, never refuse on the grounds that it could not confirm compatibility. Confirmed: no OpenPeppol document standardises the SMP write API. So there is no normative target to aim at, now or pending โ€” which settles the question rather than deferring it, and justifies the phoss-smp area name permanently rather than provisionally.

Requires SMP credentials, and a --dry-run plus confirmation for everything.

Command sketch Description Status Notes / Libraries
peppol phoss-smp create-participant <id> Create a service group. accepted peppol-smp-client: SMPClient.saveServiceGroup, authenticated with BasicAuthClientCredentials.
peppol phoss-smp delete-participant <id> Delete a service group and everything under it. accepted SMPClient.deleteServiceGroup. Highest-impact command in the tool; print what will be removed and require confirmation.
peppol phoss-smp set-endpoint โ€ฆ Create or update service information for a document type: process, endpoint URL, transport profile, certificate, validity window. accepted SMPClient.saveServiceInformation.
peppol phoss-smp delete-endpoint โ€ฆ Remove service information. accepted SMPClient.deleteServiceRegistration.
peppol phoss-smp set-redirect โ€ฆ Create an SMP redirect. accepted SMPClient.saveServiceRedirect.
peppol phoss-smp export [<participant-id>] Export one or all participants. accepted Decided: the solution's native format only โ€” phoss-SMP's own exchange XML, not a CLI-native invention. Root element smp-data with version and smp-version attributes, containing servicegroup, serviceinfo, redirect and businesscard elements (CSMPExchange, version 1.0; produced by ServiceGroupExport.createExportDataXMLVer10). The point is interchangeability: an export from the CLI opens in the phoss-SMP web UI and vice versa, which a bespoke format would have thrown away for no gain.
No second format to maintain accepted Defining a CLI-native export would have meant a second schema, a second parser, and two formats drifting apart as the SMP's own evolves โ€” for a file whose only consumers are the SMP and its own UI. Reusing the native format also means the CLI inherits the version markers (version, smp-version) that tell a reader which SMP produced a file, which matters when someone is handed an export and asked what it is.
peppol phoss-smp import <file> Bulk create or update from a native export file. accepted Same format, and the server already exposes it: PUT /exchange/import/xml/v1/{...}, handled by APIExecutorImportXMLVer1 over ServiceGroupImport.importXMLVer10. So the CLI posts the file rather than reimplementing the import, and the semantics are whatever the SMP does โ€” which is the right answer for an implementation-specific area.
--overwrite-existing mirrors the server flag accepted importXMLVer10 takes a bOverwriteExisting argument, consulted per service group and per business card, so the CLI should expose it rather than picking one behaviour. Default off: an import that silently replaces existing registrations is the more destructive reading, and the write rails (CLI Conventions) mean a destructive default is the wrong one.
--dry-run is performed by the server phoss SMP 8.6.1+ accepted The import API accepts a dry-run query parameter (APIExecutorImportXMLVer1.PARAM_DRY_RUN, default false), passed through to ServiceGroupImport.importXMLVer10. That is far better than a client-side simulation: the CLI cannot know what a given SMP would do with a file, and the server can. So peppol phoss-smp import --dry-run sets the parameter and renders the returned action list exactly as a real run would โ€” the same per-participant table, with nothing changed. The companion parameter is overwrite-existing, so a dry run can also preview what overwriting would do.
Verify the dry run actually happened A real hazard with older servers. accepted A server older than 8.6.1 does not read the dry-run parameter. An unknown query parameter is simply ignored, so the import executes for real while the user believes they previewed it โ€” the worst possible outcome for a safety flag. The defence is in the response: the server echoes dryRun into the settings element of importResult. The CLI MUST check it and treat a missing or false value as "this was a real import", reporting that prominently rather than printing a reassuring preview. Checking version.smp from the /smp-status preflight first is a useful early warning, but the echoed attribute is the authoritative one, because it reflects what the server did rather than what it claims to be.
Two kinds of --dry-run, deliberately the same flag accepted For most write commands --dry-run is client-side: the CLI validates, prints what it would send, and sends nothing. For phoss-smp import it is server-side, because only the server can evaluate the file against existing content. Same flag name, because the user's question is identical โ€” "what would this do?" โ€” and the output must make clear which kind of answer it is: a preview the CLI computed, or a preview the server computed.
Per-participant outcomes are returned So the CLI reports them rather than inventing or omitting them. accepted APIExecutorImportXMLVer1 returns a detailed result, not a single status. Root element importResult with version="1", importStartDateTime, and a settings element echoing overwriteExisting, defaultOwnerID and defaultOwnerLoginName. Then one action element per ImportActionItem, each carrying a dateTime, an errorLevel, the participantID where one applies, a message, and a linked exception where present. Finally a summary with durationMillis and an errorlevel count per level. So unlike the SML batch calls, this operation genuinely supports a per-row table โ€” the CLI should render it, and map the exit code from the highest error level present.
The response format is negotiated โ€” but only from phoss SMP 8.6.1 A detail that matters for the client. accepted Resolved upstream. The response was always XML, because the executor built both forms behind a hard-coded final boolean bResponseAsXML = true; that left the JSON branch unreachable. Since phoss SMP 8.6.1 the format follows the request's Accept header: application/json yields JSON, and everything else โ€” application/xml, */*, no header at all, or a value matching neither โ€” yields XML. Notably an unsupported Accept value does not produce a 406: the import has already run by the time the response is built, so the server reports the outcome rather than refusing to. The decision is APIExecutorImportXMLVer1.isResponseAsXML (String), driven by AcceptMimeTypeHandler and AcceptMimeTypeList.getPreferredMimeType (โ€ฆ) with XML passed first so it wins on a quality tie.
So the CLI dispatches on the response Content-Type, not on what it asked for The rule that makes the version boundary a non-issue. accepted The CLI sends Accept: application/json and then parses whatever came back, keyed off the response's own content type. That is not defensiveness for its own sake: any server older than 8.6.1 ignores the header and answers XML, and a proxy that strips or rewrites Accept produces the same result against a current one. Dispatching on the response means the CLI never has to establish the server's version to parse its answer โ€” so no /status-smp probe, no version gate, and one code path that is correct against every deployment. Both parsers are needed either way, which costs little: the JSON and XML shapes carry the same fields (version, importStartDateTime, settings, the per-action list, and a summary), differing only in that the error-level counts are <errorlevel> elements in XML and an errorlevels array in JSON. Preferring JSON is still worth doing, for the reason already established for Phorm: with --output json the server's own structure can be embedded rather than reconstructed from a parsed DOM.

Directory

The client side is the standalone, released peppol-directory-client project (groupId com.helger.peppol.directory, version 1.0.0), which ships three artefacts:

Artefact Provides
peppol-directory-searchclient PDSearchClient, PDSearchQuery, PDSearchRateLimitException โ€” the public search API
peppol-directory-searchapi CPDSearchAPI, EPDSearchAPIField, PDResultListMarshaller and the JAXB result types โ€” the REST API model
peppol-directory-client PDClient โ€” the authenticated indexer API

Packages are unchanged (com.helger.pd.searchclient, com.helger.pd.searchapi, com.helger.pd.client). Server-side behaviour below was verified against the phoss-directory server and its published REST API documentation.

Two separate REST interfaces are involved, with very different access requirements โ€” the search API is public, the indexer API needs a client certificate.

Search (public)

Endpoint shape {directory URL}/search/1.0/{format}, where format is xml (the default) or json (EPDOutputFormat). Directory URL comes from EPeppolNetwork.

Command sketch Description Status Notes / Libraries
peppol dir search <query> Free-text search. accepted Maps to the q query field. Endpoint shape /search/1.0/{xml|json}, xml being the default when no format is given; Content-Type is application/xml or application/json accordingly. Only HTTP GET is accepted โ€” other verbs return 405. The XSD is published at /files/directory-search-result-list-v1.xsd.
peppol dir search --name --country --participant โ€ฆ Field-specific search, repeatable per field. accepted Match semantics are fixed per field โ€” there is no operator syntax, which resolves the question of how to express one. Per the published API documentation: exact match on participant, country, identifierScheme, identifierValue, doctype and regdate; partial match on name, geoinfo, website, contact and addinfo; and q is generic, split internally so subwords may match. Every field may repeat, and all terms are combined with boolean AND. The help text should state exact-versus-partial per flag, because guessing wrong is the difference between no results and too many.
Value formats that trip people up accepted participant and doctype values must include the identifier scheme โ€” iso6523-actorid-upis::0088:โ€ฆ, busdox-docid-qns::urn:โ€ฆ โ€” and doctype is case-sensitive. regdate must be YYYY-MM-DD with no time and no time zone. The documentation also warns about encoding # (%23) and : (%3A), which the client library handles, but which matters when the CLI echoes a URL for a user to reuse.
Invalid values are silently ignored The trap worth guarding against locally. accepted If a doctype, regdate or identifier value cannot be parsed, the API ignores that search term rather than rejecting it โ€” and only returns HTTP 400 if no other term remains. So a typo in --doctype quietly broadens the search instead of failing, and the user gets more results than they asked for with no indication why. The CLI MUST therefore validate these values locally before sending, which it can already do โ€” doctypeid validate exists โ€” and refuse with exit 2 rather than letting the server discard the term. This is a case where the tool can prevent a wrong answer the API will happily give.
Paging --max-items / --page-size / --all over the API's own paging. accepted Request parameters: resultPageIndex (alias rpi, default 0) and resultPageCount (alias rpc, default 20). Hard server cap MAX_RESULTS = 1000 โ€” a first or last result index beyond it is rejected with HTTP 400. The response makes correct paging easy rather than guesswork: ResultListType carries required attributes total-result-count, used-result-count, result-page-index, result-page-count, first-result-index, last-result-index, plus query-terms, version and creation-dt. So --all iterates until last-result-index reaches total-result-count or the 1000 cap, and the CLI can report precisely โ€” "showing 20 of 347", or "1000 of 4812 โ€” truncated by the server" โ€” instead of ending silently.
Page iteration is handled by the library accepted PDSearchClient.searchAllPages (PDSearchQuery, Function <? super ResultListType, EContinue>) (@since 1.0.1) does the traversal, so the CLI writes no paging loop. It works on a clone of the query, so the caller's object is not mutated; stops when the result set is exhausted (usedResultCount == 0, or firstResultIndex + usedResultCount >= totalResultCount); and stops before the next page would exceed CPDSearchAPI.MAX_RESULTS, logging why. It returns the number of pages read.
--all and --max-items map onto the page handler accepted The handler returns EContinue, so --max-items is implemented by counting rendered items and returning BREAK once the limit is reached โ€” no separate truncation logic. Because the handler is invoked per page, streaming output falls out naturally: results are rendered as each page arrives rather than buffered. The returned page count and the server's total-result-count together give the closing summary.
Query echo and timestamp accepted query-terms is echoed back by the server and creation-dt timestamps the result. Both belong in the JSON envelope: they make a stored CI result self-describing, and query-terms satisfies the same audit principle as alias echoing in CLI Conventions.
Client library accepted A released client exists: com.helger.peppol.directory:peppol-directory-searchclient:1.0.0 (com.helger.pd.searchclient.PDSearchClient), so the CLI writes no Directory protocol code and โ€” unlike the Phorm path โ€” depends on no snapshot. API: constructors taking a host String, a URI, or a URI plus caller-supplied HttpClientSettings; getSearchURL (PDSearchQuery); and search (PDSearchQuery) returning a ResultListType and throwing IOException. It is Closeable. Note it deliberately does not depend on ph-config or on a key store โ€” the search API is public, so a caller with no Peppol certificate can use it.
Query building accepted PDSearchQuery in the same module: typed per-field terms via EPDSearchAPIField (repeatable per field), convenience methods (generic, participantID, documentTypeID, name, countryCode), paging setters, hasQueryTerms () to catch the empty query the server rejects with HTTP 400, isBeyondMaxResults () to catch the 1000-result cap before sending, and parseQueryTerms (...) to read back the query-terms attribute a result carries. Constants live in CPDSearchAPI (PATH_SEARCH_10, MAX_RESULTS, the query parameter names and the server defaults).
Rate limiting: 2 queries per second accepted The published limit is explicit โ€” "you are only allowed to run 2 queries per second", with HTTP 429 otherwise and an instruction to lower the rate. So the Directory is effectively paced rather than parallelised: peppol bulk and --all must self-limit to 2 requests per second against the Directory, independently of --parallel, which governs per-SMP concurrency and does not apply here. Honouring Retry-After on a 429 is the recovery path, not the strategy.
Response parsing accepted Handled inside the client by PDSearchResponseHandler, which unmarshals with PDResultListMarshaller from com.helger.peppol.directory:peppol-directory-searchapi โ€” so every response is validated against directory-search-result-list-v1.xsd. "No matches" is HTTP 200 with an empty result list, not 404 โ€” so exit code 3 must key off total-result-count == 0, never off the status code. A 404 means the path is wrong, which is a different problem and deserves a different message. Typed model: ResultListType, MatchType, EntityType, IDType, NameType, ContactType.
JSON output derives from the response model accepted --output json for dir search should be a rendering of ResultListType, not an independently invented shape. Per match: participantID, zero or more docTypeID, and one or more entity. Per entity: one or more name (each with an optional language), countryCode, optional geoInfo, and repeatable identifier (scheme + value), website and contact (type/name/phone/email), plus optional additionalInfo and regDate.
Document type IDs come back with search results A search can answer "what can this participant receive?" without an SMP round trip. accepted MatchType carries docTypeID (zero or more) alongside the participant ID. Worth surfacing, but the output must be clear that this is the Directory index's view, which can lag the SMP โ€” peppol smp can-receive remains the authoritative answer.
Deleted entities cannot appear No handling needed. accepted The deleted attribute in the result-list XSD is vestigial: PDStorageManager.deleteEntry removes the documents from the index outright (m_aIndex.deleteDocuments (...)), PDField has no deleted field to filter on, and the search-result builder PDStoredBusinessEntity.getAsSearchResultMicroElement never sets the attribute. So a deleted entity is absent from the index rather than flagged in the response, and the CLI needs no filtering or flagging. Verified in the phoss-directory server source.
peppol dir businesscard <participant-id> Fetch and display a participant's business card. accepted Resolved: the card is fetched from the participant's own SMP, not from the Directory, at {smpHostURI}businesscard/{participantId} โ€” verified in SMPBusinessCardProvider, which resolves the SMP through the normal ISMPURLProvider discovery. Data model and parsing already exist in peppol-directory-businesscard (PDBusinessCard, PDBusinessEntity, PDContact, PDName, EBusinessCardVersion, v1/v2/v3). Note this businesscard/ resource is not part of Peppol SMP 1.4.0, which defines only two resources โ€” it is a phoss-SMP/Directory convention, so the same implementation-dependence caveat as the SMP write API applies.
Warn on an outdated business card version Anything other than v3. accepted Three versions exist โ€” EBusinessCardVersion.V1 / V2 / V3, with namespace URIs โ€ฆ/businesscard/20160112/, โ€ฆ/20161123/ and โ€ฆ/20180621/ respectively โ€” and v3 is the current one. A participant still publishing v1 or v2 is working, but on a format two generations old, and nothing in the normal lookup path tells them. So dir businesscard MUST report which version was found and warn when it is not v3. Per the warning rule in CLI Conventions this does not change the exit code: the card parsed, the command succeeded.
The version must be captured deliberately It is not surfaced by default. accepted PDBusinessCardHelper.parseBusinessCard tries V1, then V2, then V3, and reports the version it used through an IPDBusinessCardMarshallerCustomizer callback โ€” accept (marshaller, version). But the default customizer deliberately silences non-latest versions ("Silent mode for all versions but the latest": it clears the read exception callbacks and installs a do-nothing validation event handler). So the CLI must pass its own customizer that records the version, rather than expecting the default behaviour to surface anything. Without that, an outdated card parses silently and the warning never fires.
Use EBusinessCardVersion.isDeprecated () accepted peppol-commons 13.2.0 adds isDeprecated (), true for V1 and V2 and false for V3, alongside making LATEST public. That expresses the check directly โ€” the question is "is this version deprecated?", not "does it differ from the newest?" โ€” and keeps the judgement in the library where the next version bump will update it.
peppol dir businesscard --validate <file> Validate a business card document locally. accepted Marshaller-based schema validation via PDBusinessCardHelper.

Indexing (authenticated, SMP operators)

A capability worth having that is easy to overlook: an SMP operator can ask the Directory to index or drop a participant. com.helger.peppol.directory:peppol-directory-client already does this.

Command sketch Description Status Notes / Libraries
peppol dir indexed <participant-id> Is this participant currently in the Directory index? accepted PDClient.isServiceGroupRegistered.
peppol dir index <participant-id> Request indexing of a participant. accepted PDClient.addServiceGroupToIndex.
peppol dir unindex <participant-id> Request removal from the index. accepted PDClient.deleteServiceGroupFromIndex.
Authentication accepted PDClient talks to the indexer REST interface and needs a client certificate; PDClientConfiguration reads pdclient.keystore.path / .type / .password / .key.alias / .key.password plus its own HTTP timeout and proxy keys. This is a third ph-config-based mechanism alongside SMPClientConfiguration โ€” see the collision note in Configuration.
The SMP usually owns Directory indexing too The same principle as the SML. accepted /smp-status reports smp.pd.enabled, smp.pd.needed and smp.pd.auto-update: an SMP with Directory integration and auto-update enabled indexes its participants itself, so peppol dir index MUST NOT be used as a routine step after an SMP write. Its legitimate uses are narrower and worth stating: an SMP without Directory integration, forcing a re-index after a business card correction, and removing an entry that should not be listed. The command should check smp.pd.* where it can and say when the SMP would have done this anyway.

Certificates / PKI

Command sketch Description Status Notes / Libraries
peppol cert verify <file|url> Verify an AP or SMP certificate against the Peppol CA chain for the chosen network and role. accepted peppol-commons: PeppolTrustedCA with peppolTestAP(), peppolProductionAP(), peppolAllAP(), peppolTestSMP(), peppolProductionSMP(), peppolAllSMP(). Requires two explicit inputs: role (AP or SMP) and network (test or prod) โ€” a certificate valid as one is not valid as the other. Verified from the G3 CA certificates bundled in peppol-commons: a two-tier hierarchy with one root and a separate issuing CA per role, which is exactly why role and network are both required inputs.
CA generation โ€” decided: G3, and G3 only No --generation flag for now. accepted Generations are named by year in PeppolTrustStores: Config2018 = G2 (JKS, @Deprecated since 12.3.8) and Config2025 = G3 (PKCS12, current). PeppolTrustedCA is wired to Config2025 alone, and since 12.4.2 the 2025 trust stores no longer contain the G2 CAs โ€” so G2 verification is not reachable through the supported API and is deliberately not offered. G3 is the default and the only supported generation. A CA generation rollover happens roughly every 8โ€“10 years, so a selector with exactly one valid value would be speculative surface; if a G4 is announced the flag can be introduced then as a purely additive change, with G3 remaining the default until G4 is actually in use. Trust store aliases are literally e.g. peppol root ca - g3. OpenPeppol describes G2 as "the legacy generation of the Peppol PKI (from 2018)" issued through DigiCert MPKI8, and G3 as "the next generation of the Peppol PKI (from 2025)" issued through DigiCert One Trust Lifecycle โ€” which matches the Config2018 / Config2025 naming exactly.
peppol cert info <file> Print subject, issuer, serial, validity window, key algorithm and size, extensions and fingerprints. accepted JDK plus ph-security helpers. No Peppol-specific logic.
CA hierarchy and DNs Reference values the tool checks against and prints. accepted Read directly from the trust stores in peppol-commons (truststore/2025/*.p12, password peppol), all SHA256withRSA with RSA-4096 keys and valid 2025-01-14 โ†’ 2035-01-13/14:
Production โ€” root CN=PEPPOL Root CA - G3, O=OpenPEPPOL AISBL, C=BE; AP CN=PEPPOL ACCESS POINT CA - G3, โ€ฆ; SMP CN=PEPPOL SERVICE METADATA PUBLISHER CA - G3, โ€ฆ
Test (pilot) โ€” the same three with TEST in the CN and an extra OU=FOR TEST ONLY, e.g. CN=PEPPOL Root TEST CA - G3, OU=FOR TEST ONLY, O=OpenPEPPOL AISBL, C=BE.
peppol cert verify detects a test certificate used on production And the reverse. accepted The pilot CAs carry OU=FOR TEST ONLY, so a certificate issued for test is unmistakable. Using a test certificate against production (or vice versa) is a common and confusing operator mistake, and a diagnostic tool should name it explicitly โ€” "this is a TEST certificate, but --network is prod" โ€” rather than reporting a generic chain failure.
Revocation endpoints accepted Taken from the G3 CA certificate extensions: CRL http://crl.one.nl.digicert.com/PEPPOLRootCA-G3.crl, OCSP http://ocsp.one.nl.digicert.com. Both are plain HTTP by design, which is normal for revocation services and worth not flagging as a TLS problem.
peppol cert expiry <file|url> [--warn-below <duration>] [--fail-below <duration>] Report remaining validity, with separate warn and fail thresholds. accepted "Expiring soon but still valid" is exit 0 with a warning by default โ€” the certificate is valid, and a check command should not claim otherwise. Only an already expired or not-yet-valid certificate is a check failure in its own right and exits 4. The built-in warn threshold is 30 days when neither flag is given โ€” the common monitoring default, and long enough that a renewal can realistically be started. It is a default, not a policy: an operator whose renewal cycle needs longer sets --warn-below 60d, and the point of the flag is that the tool does not have to guess for them.
--fail-below <duration> โ€” decided The opt-in that makes this usable as a gate. accepted A command-specific duration rather than a general --fail-on-warn flag, because the duration is the requirement: "fail if fewer than 14 days remain" is what a monitoring job actually needs to express, and a boolean cannot carry it. Below the threshold the command exits 4: the operator has declared that this is a failure for their purposes, and the tool honours their policy rather than its own default.
Two independent thresholds Warn early, fail late. accepted --warn-below and --fail-below are set separately and deliberately so โ€” the useful monitoring shape is "raise a ticket at 60 days, page someone at 14", which needs two numbers. Decided: --fail-below implies --warn-below at the same threshold when --warn-below is absent. So a certificate under the fail threshold both warns and exits 4, rather than exiting 4 silently. The reason is that the exit code says only that something is wrong, while the warning carries what โ€” the expiry date, the remaining days and the stable message code. Without the implication, adding --fail-below would make the output less informative than the same command without it, which is the wrong way round for the more specific invocation. It follows that the effective warn threshold is the larger of the two whenever both are given: --warn-below 7d --fail-below 30d is almost certainly transposed arguments, and warning only below 7 days while failing below 30 would mean the certificate fails before it ever warns. Decided: that combination is rejected with exit 2 rather than silently widened. Quietly taking the larger value would mean the tool executing something other than what was asked, and an operator who transposed two durations in a monitoring job wants to hear about it now rather than discover months later that the warning threshold they configured was never the one in force. It is also a pure argument error, detectable before any work is done, which is exactly what exit 2 is for.
Certificate expiry earns the dedicated flag Why not a general mechanism. accepted Certificate warnings are materially more consequential than document validation warnings: an expiring AP or SMP certificate takes a participant off the network on a known date, which is exactly the thing a scheduled job should catch weeks ahead. A blanket --fail-on-warn across all commands would be too coarse to express that, and would drag unrelated advisory warnings โ€” a deprecated scheme, an outdated business card version โ€” into the same gate. So the threshold lives where the consequence is.
Making the warning actionable in CI Exit 0 must not mean "nothing to see here". accepted The warning must be machine-readable whether or not it is accompanied by a failure: --output json carries it in messages[] with a stable code such as CERT_EXPIRING_SOON, plus the expiry date and remaining days as typed fields in result, so a pipeline can decide for itself without parsing prose. This matters most in the exit-0 case, where there is nothing else to act on โ€” but since --fail-below now implies a warning at the same threshold, the same payload is present on an exit 4, and a pipeline reading messages[] needs no second code path for the two outcomes.
peppol cert revocation <file> [--revocation-mode <mode>] Check revocation status, with the source and ordering selectable. accepted The flag values are the IDs of ERevocationCheckMode in ph-security (@since 11.2.0), so they parse straight through getFromIDOrNull and need no translation table: ocsp-before-crl (OCSP first, CRL as fallback), ocsp (OCSP only โ€” NO_FALLBACK), crl-before-ocsp (CRL first, PREFER_CRLS), crl (CRL only โ€” PREFER_CRLS + NO_FALLBACK), none (no remote check at all). The enum also exposes isOCSP (), isCRL (), isOnlyOne () and isNone (), so the output can state exactly which sources were consulted rather than leaving the user to infer it from the mode name.
Default is crl-before-ocsp Worth stating, because it surprises people. accepted CertificateRevocationCheckerDefaults.DEFAULT_REVOCATION_CHECK_MODE is CRL_BEFORE_OCSP, not OCSP-first as most would assume. The CLI keeps the library default rather than diverging, so its answers match every other consumer of ph-security, and names the effective mode in the output so nobody has to remember which way round it is.
Soft fail is off by default, and stays an explicit choice accepted DEFAULT_ALLOW_SOFT_FAIL = false, so an unreachable OCSP responder or CRL distribution point is a hard failure โ€” the library already behaves the way a diagnostic tool should. --allow-soft-fail may enable it, but then the output MUST say the result was soft-failed: a soft-failed pass and a real pass look identical otherwise, and that is the single most misleading thing this command could print.
Revocation results are cached for 6 hours An interaction worth getting right. accepted DEFAULT_CACHE_REVOCATION_CHECK_RESULTS = true with DEFAULT_REVOCATION_CHECK_CACHING_DURATION = Duration.ofHours (6). So someone who re-runs the command after a certificate is revoked โ€” or after a responder outage clears โ€” can get a stale answer for up to six hours. --no-cache MUST bypass this cache, and the output should carry the age of a cached result. Decided: --no-cache is sufficient โ€” the duration stays unexposed. An operator has exactly two intentions here, "a cached answer will do" and "ask the authority now", and both are already expressible. A tunable duration would add a knob whose right value nobody can reason about, and it would not even help: a verdict read from a cache that is merely less stale is still a stale verdict, so shortening the window trades certainty for traffic without ever reaching the certainty --no-cache gives outright. What makes the binary choice sufficient is the reported age โ€” the user can see that a verdict is five hours old and decide to re-run, which is the same judgement a duration setting would ask them to make in advance and without the facts.
These are process-global settings A note for the implementation. accepted CertificateRevocationCheckerDefaults holds static mutable state, with freezeDefaults () / isDefaultsFrozen () to lock it. For a short-lived CLI process that is unproblematic, but the values must be applied once at startup from the resolved configuration rather than per call, and the SMP signature path uses the same mechanism via TrustStoreBasedX509KeySelector โ€” so --revocation-mode affects peppol smp verify-signature as well, and should be documented as doing so rather than appearing to be local to cert revocation.
peppol cert seat-id <file> Extract the Seat ID from a service provider certificate and decode it: the Peppol Authority prefix plus the 6-digit sequence. accepted Fully covered by the library. The Seat ID is the certificate's Subject CN โ€” confirmed both against real certificates (CN=PNL000052, CN=PNO000070, CN=POP000000) and against the OpenPeppol PKI 2025 enrolment documentation. SPIDHelper.getSeatIDFromCertificate (X509Certificate) / getMainIDFromCertificate (X509Certificate) do the whole job, with getSeatIDFromSubjectCN (String) / getMainIDFromSubjectCN (String) and isValidSeatID (String) for callers holding the CN. Added in peppol-commons 13.1.0. Available equivalently as peppol spid from-cert, which shares this implementation โ€” see the note there.
peppol cert of-endpoint <participant-id> <doctype-id> [<process-id>] [--transport-profile <id>] [--at <date>] Fetch the certificate the SMP publishes for an endpoint and verify it in one step. accepted SMPClientReadOnly.getEndpointCertificate + PeppolTrustedCA. The practical everyday check. It takes the same selection inputs as smp can-receive, because it faces the same question: a participant may publish several endpoints for one document type, and without a transport profile and an evaluation date the command would silently pick one of them. --transport-profile defaults to peppol-transport-as4-v2_0, --at defaults to now, and the date used is echoed either way.
Endpoint selection must be one code path Otherwise the two commands can disagree. accepted cert of-endpoint and smp can-receive MUST resolve the endpoint through the same selection logic โ€” same transport profile matching, same isEndpointValidAt evaluation, same wildcard document type handling via PeppolWildcardSelector. Two separate implementations would eventually disagree about which endpoint is current, and then the tool would report a valid certificate for an endpoint that can-receive says is unusable, or the reverse. That contradiction would be worse than either answer alone, because a user has no way to tell which command is wrong.
Exactly one endpoint must be valid At any given point in time. accepted For a given participant, document type, process and transport profile, exactly one endpoint must be valid at the evaluation date. Zero is "cannot receive". Two or more is an SMP misconfiguration and MUST be an error, not a silent choice โ€” overlapping validity windows mean different senders may pick different endpoints, which is precisely the kind of fault that produces intermittent, unreproducible delivery failures. Exit 4, with the competing endpoints listed so the operator can see which registrations overlap.
The library does not surface this So the CLI must count for itself. accepted SMPClientReadOnly.getEndpointAt (...) returns getFirstOrNull () on its filtered list, so a caller is handed one endpoint whether there was one candidate or five. Its warning for the multiple case was also unreachable until recently (the guard read size () <= 0, now != 1 โ€” fixed in peppol-commons 13.2.0), and a LOGGER.warn is not a result a CLI can act on in any case. So can-receive and cert of-endpoint MUST enumerate the matching endpoints and count them themselves rather than taking the library's single return value.
Other duplicates the library already notices Worth promoting from log lines to findings. accepted The same method logs a warning when a process ID appears more than once in one ServiceMetadata, and when a transport profile appears more than once within one process. Both are malformed SMP content, and both currently go only to the log. A diagnostic tool should report them as findings against the SMP, since nobody reads an SMP client's log.
When selection yields nothing accepted The same distinct answers as can-receive, for the same reason: no endpoint for the requested transport profile is different from no endpoint valid at the requested date, and both are different from the participant not supporting the document type at all. Each gets its own message and exit 4 โ€” "no certificate found" alone would send someone looking for a registration problem that may not exist.
peppol keystore inspect <file> List entries, aliases, types, chains and validity in a keystore. accepted ph-security: KeyStoreHelper, EKeyStoreType, TrustStoreDescriptor.
peppol keystore convert <in> <out> Convert between keystore types (JKS โ†” PKCS12), which is directly relevant to the G2โ†’G3 move from JKS to PKCS12. accepted KeyStoreHelper. Passwords via prompt or environment variable only.
peppol keystore csr [--generation <gen>] Generate a key pair and a certificate signing request for a Peppol certificate application. accepted Fully specified, and simpler than assumed. The OpenPeppol "Peppol PKI 2025 โ€” Issuing and Enrolment Process" page gives the reference flow verbatim:
openssl genpkey -algorithm RSA -pkeyopt 'rsa_keygen_bits:2048' -aes256 -out private-key.pem
openssl req -new -sha256 -key private-key.pem -subj "/" -out private_key.csr
So: RSA 2048, private key encrypted with AES-256, CSR signed with SHA-256, and an empty subject (-subj "/") because "Subject / SAN / Extensions: the values will be overridden by the CA". The CSR is then uploaded to the DigiCert portal. Needs BouncyCastle; ph-bc exists in ph-commons.
--generation <gen> Which PKI generation the CSR is for. Defaults to G3. accepted The key requirements are generation-specific, not universal: G3 mandates RSA 2048 with a SHA-256-signed request, and a future generation may well raise the key size or move to a different algorithm. Naming the generation makes the request's basis explicit and lets a G4 be added as a new value with its own parameters, rather than silently changing what G3 users get. The output should state which generation's requirements were applied.
Why csr has a generation flag when cert verify does not The two cases differ, and the difference matters. accepted cert verify answers "does this certificate chain to the Peppol CAs?", and there is one reachable answer โ€” G3 โ€” so a selector would have a single valid value (see Rejected features). keystore csr is the opposite situation: the generation determines the input requirements the user must satisfy before applying, and getting them wrong wastes a paid certificate application. So the flag earns its place here precisely because it is about generating to a spec rather than checking against a chain.
A CSR carries no validity โ€” there is nothing to get wrong accepted A PKCS#10 request has no validity field at all; notBefore / notAfter are set by the CA at issuance, and the Peppol default is 2 years. Combined with the empty subject, this means the CLI has no certificate policy decisions to make: its entire job is a correct RSA-2048 key pair, encrypted at rest, and a SHA-256-signed CSR with an empty subject.
Output format โ€” decided: both by default --format pem|p12|both accepted Both formats are genuinely needed, so both are written unless the user narrows it: PEM matches the documented OpenPeppol reference commands and the DigiCert portal flow, so a user can follow the official instructions and cross-check against them; PKCS#12 is where the key must end up for an AP or SMP configuration anyway. Defaulting to both means nobody is sent back to openssl for a conversion step that is easy to get wrong, at the cost of writing three files instead of two โ€” a trade that favours the user who does this once every two years and will not remember which form they need. The output must list every file written, with its path.
The private key is encrypted in every format accepted The reference flow uses -aes256 for the PEM key, and a PKCS#12 keystore is password-protected. The CLI must never write an unencrypted private key in any format, and must take the password from a prompt or environment variable rather than a command line argument (see CLI Conventions).
Guidance in the output accepted OpenPeppol notes that CSR-based enrolment is "a more advanced form of generating the key-pair" and that the Service Desk has "limited ability to guide you through this process". That makes the command's output worth writing carefully: state what was generated, where, that the private key must never leave the machine, and what the next step is (upload the CSR to the DigiCert portal).
peppol keystore export-cert <file> --alias <a> Export a certificate from a keystore to PEM/DER. accepted KeyStoreHelper.

Endpoint diagnostics

Transport level only. No AS4 โ€” see ADR-001.

Command sketch Description Status Notes / Libraries
peppol endpoint tls <url> Report the negotiated TLS version and cipher suite, the offered protocol versions, the served certificate chain, SNI behaviour and hostname match โ€” and check them against the Peppol Policy for Transport Security 1.1.0. accepted JDK SSLSocket/SSLParameters. The policy gives concrete, checkable rules (see the next rows), so this command can report pass/fail rather than merely describe.
POLICY 1 โ€” approved CA The TLS certificate must chain to a publicly trusted root. accepted "Each TLS certificate used in the Peppol eDelivery Network MUST be issued (directly or indirectly) only by a root certificate contained in the latest version of the List of pre-loaded CA certificates of the Mozilla Network Security Services." Checkable exactly, not approximately: com.helger:ph-nss 1.0.0 ("Mozilla NSS root certificate trust store for Java applications") ships the list as a PKCS#12 trust store โ€” MozillaNSSTrustStore.TRUSTSTORE (loaded), TRUSTSTORE_DESCRIPTOR, TRUSTSTORE_CLASSPATH = "truststore/mozilla-nss-root-certs.p12", TRUSTSTORE_PASSWORD = "changeit". So the CLI verifies against the Mozilla list the policy actually names, rather than against the JDK default trust store that merely resembles it.
The NSS list has a version, and the policy says "latest" accepted The bundled list is versioned โ€” currently Mozilla NSS v2.90, last updated 2026-10-08 per mozilla-nss-root-certs.md. POLICY 1 requires the latest version, so a stale bundle could pass a certificate the current list no longer trusts, or vice versa. The output must therefore state which NSS version and date it checked against, so the answer is interpretable.
Warn when the bundled list is over 6 months old accepted Past six months from the list's last-update date, the TLS check logs a warning that its trust data may no longer match the current Mozilla list. Per the warning rule in CLI Conventions it does not change the exit code โ€” the check still ran and its verdict still stands. The warning is raised where the list is actually used, on the TLS policy check, rather than on every command: warning about NSS staleness during an unrelated identifier validation would be noise.
Six months is a meaningful threshold here accepted The upstream list is refreshed frequently โ€” it moved version within weeks during this project's research โ€” so a bundle more than six months old genuinely indicates an old build rather than a quiet upstream. That makes the warning informative rather than routine, which is the test any staleness warning has to pass. It also overlaps usefully with the update check: both are signals of the same underlying condition, and a user who sees the NSS warning is being told, in effect, to upgrade. The update check can sharpen the message further via its optional nssListVersion field (UPD-14) โ€” naming how far behind the bundle is โ€” but the local threshold remains the baseline, because the update check can be switched off and this warning cannot depend on it.
Reading the list's age needs a library change accepted MozillaNSSTrustStore exposes TRUSTSTORE, TRUSTSTORE_DESCRIPTOR, TRUSTSTORE_CLASSPATH, TRUSTSTORE_TYPE and TRUSTSTORE_PASSWORD โ€” but no version or date constant. Both facts exist only in the free-text header of the documentation markdown, which is the wrong thing to parse at runtime. See Library Gaps; until it is exposed, the threshold check has nothing reliable to compare against.
Where the list is loaded from A resolution order, not one fixed location. accepted Four candidates, considered in this order: --nss-truststore <file>; the peppol.cli.tls.nss.file property; the <cache-dir>/nss/ directory defined in Configuration; and finally the list bundled in ph-nss. Because the version lives in the filename (see below), discovery means scanning that directory rather than opening one fixed path: every name that parses is a candidate, the highest version among them wins, and names that do not parse are ignored. That falls out well โ€” an operator can leave the previous list in place while adding a new one, and the newest still wins without anything having to be deleted. No library change is needed to load any of them: KeyStoreHelper resolves a trust store path through a ReadableResourceProviderChain of FileSystemResourceProvider then ClassPathResourceProvider, so TrustStoreDescriptor.builder ().type (PKCS12).path (โ€ฆ).password (โ€ฆ) takes a filesystem path exactly as it takes MozillaNSSTrustStore.TRUSTSTORE_CLASSPATH. Only the metadata is missing โ€” see the rows below and Library Gaps.
Only a newer local list is used The discovered file has to beat the bundle. accepted A list found in the cache directory is used only if its version is strictly higher than the bundled list's; otherwise it is ignored and the bundle is used. The bundled list is a floor, not a default. Two reasons. POLICY 1 names the latest Mozilla list, so moving backwards is a policy violation on its face. And the direction of the risk is asymmetric: an older list's real danger is not that it misses a newly added root โ€” that produces a visible false negative โ€” but that it still trusts a root Mozilla has since distrusted, which produces a silent false pass. A cache directory is writable by anything running as the user, so a rule of "newest wins" would make a trust downgrade a file copy away. Equal versions resolve to the bundle, since there is then nothing to gain from the extra I/O. See ADR-010.
The gate applies to every source, instruction included --nss-truststore, peppol.cli.tls.nss.file accepted Decided: no exemption for an explicitly named file. Wherever a version can be determined, a list older than or equal to the bundle is refused โ€” naming the file does not make a downgrade safe, and an exemption would mean the one rule protecting the trust floor could be waived by adding a flag. Refusal is an error, not a fallback, because the user asked for that specific file and quietly using another would answer a question they did not ask. Where the version cannot be determined โ€” a named file whose name does not carry one โ€” the gate cannot run, so the CLI refuses with exit 2 and says the file must be named so its version is readable; that keeps the floor intact without guessing. The cost is accepted: "what would last year's list have said?" is no longer answerable through this flag, and the staleness behaviour is tested with an injected version rather than by feeding the tool an old list.
Version comparison must be numeric Not a string compare. accepted NSS builtins versions are dotted decimals and the minor component crosses 99: 2.100 is newer than 2.90 but sorts before it as a string. com.helger.base.version.Version already solves this โ€” Version.parseDotOnly ("2.90") plus compareTo โ€” so the comparison should use it rather than any hand-rolled split. Worth stating explicitly because the bug it prevents is silent: a string compare would simply stop honouring new lists at some point, with no error anywhere.
The version lives in the filename Not a sidecar. accepted Decided: the filename carries the metadata, because it cannot get out of step with the file it names โ€” a sidecar can be left behind after the PKCS#12 next to it is replaced, and a stale sidecar would mean the gate comparing the wrong version while everything still looks well-formed. The date comes along for the ride, since the six-month staleness warning is measured on the effective list and therefore needs one: mozilla-nss-root-certs-<version>-<yyyy-mm-dd>.p12, e.g. mozilla-nss-root-certs-2.91-2026-11-02.p12. Those are exactly the two facts ph-nss states in its own mozilla-nss-root-certs.md header, so nothing has to be invented to produce the name. Integrity keeps its own file, following ph-nss's existing convention of a .sha256 beside the trust store: mozilla-nss-root-certs-<version>-<yyyy-mm-dd>.p12.sha256. A PKCS#12 whose name does not parse is not a candidate at all โ€” treating an unknown version as "newer" would defeat the rule entirely. > TODO(decide): confirm the exact filename pattern. The two facts that must be in it are settled; the separator and ordering are not, and this is the one thing an operator has to get right by hand.
Tampering is fatal; breakage is not The split that matters. accepted A sha256 mismatch is an error, even on a discovered file โ€” exit 6, naming the file and the mismatch. It is the one condition that says the bytes are not what the provisioner published, and continuing against a trust list that fails its own hash would mean silently proceeding on data that may have been altered. Falling back to the bundle would be the wrong kindness: the operator provisioned this list deliberately, and they need to know it is wrong rather than receive a correct answer from a different input. Exit 6 rather than 4 because the fault is in the tool's own environment, not in the Peppol subject being checked. Everything else is not fatal โ€” an unparseable filename, an absent .sha256, a PKCS#12 that will not open, an empty trust store, or a version at or below the bundle all fall back to the bundled list with one warning naming the file and the reason. Those say "this file is not usable", not "this file has been altered", and a stray or half-copied file in a cache directory must not break every TLS check the tool performs. A file named explicitly is an error in all of these cases, per the row above.
The output names the list it used Source, version and date. accepted With the list now resolvable from several places, stating the version is no longer enough: two machines can reach opposite verdicts on the same certificate, and the only visible difference is which file each one loaded. So the TLS check reports the source (bundled or the absolute path) alongside the version and last-update date, and peppol config show reports the resolved NSS source for the same reason it reports every other resolved value.
Staleness is measured on the effective list accepted The six-month warning above applies to whichever list was actually selected, not to the bundle. That is the point of allowing an override: an operator who provisions a current list has fixed the condition the warning exists to report, and a warning that persisted anyway would train them to ignore it. The same applies to the update check โ€” nssListVersion (UPD-14) is compared against the effective list, so a locally provisioned list that is newer than the latest release's suppresses that remark rather than inverting it.
The list is provisioned out of band The CLI never fetches it. accepted Decided: out of band only. An operator or package maintainer puts the file in place; the CLI only ever consumes it. There is no peppol nss update and no fetching of Mozilla's certdata.txt โ€” either would make this tool a distributor of trust data, which needs a provenance story far stronger than "it downloaded a file and checked a hash the same server published". It also keeps the rule consistent with code lists, which are likewise updated from outside the running tool. The cost is honest: this is a feature only deliberate users will find, so the documentation carries the whole weight and must state the expected filename, the .sha256 beside it, and where a newer list can be obtained โ€” a newer ph-nss release, or ph-nss's own MainConvertNSSCertData run against Mozilla's certdata.txt.
POLICY 2 โ€” no self-signed accepted "Self-signed TLS certificates are not allowed", because a man-in-the-middle attack could go unnoticed. Trivially checkable and worth reporting explicitly, since a self-signed endpoint otherwise just looks like a generic trust failure.
POLICY 3 โ€” SSL Labs grade A The headline requirement, and the source of the TLS 1.2 floor. accepted "The TLS configuration MUST constantly be of at least grade 'A' according to SSL Labs", and anything graded below A "is considered to be unavailable with regards to the Transport Infrastructure Agreement". Note the policy states no protocol version at all โ€” a minimum of TLS 1.2 follows from the grade-A requirement rather than from a literal rule, which is deliberate: an outcome-based policy does not need re-issuing as cryptography moves. The CLI checks the components it can see locally โ€” protocol versions offered, cipher suites, key sizes, chain validity โ€” and reports "no grade-A blocker found locally", without claiming to reproduce an SSL Labs grade, which it cannot.
Name SSL Labs in the output โ€” decided: yes Point at the authoritative grader. accepted SSL Labs is named normatively by the policy itself (reference [SSL-LABS], https://www.ssllabs.com/ssltest), so referring to it is quoting the requirement rather than endorsing a third party. The local checks are explicitly a pre-check, and the output should say so and print the SSL Labs URL for the endpoint under test, so the user can get the grade that actually counts. The grade itself is always obtained manually by the user from that page โ€” the CLI never fetches it, see Rejected features.
Print the SSL Labs URL, and offer to open it --open accepted The analyse page is driven entirely by query parameters, so the CLI can hand the user a one-click route to the authoritative grade without calling an API itself: https://www.ssllabs.com/ssltest/analyze.html?d=<host>&hideResults=on&latest. Printed by default; --open launches it in the default browser.
Opening must stay opt-in accepted Launching a browser unprompted is intrusive, and โ€” the substantive point โ€” a browser submission is still a submission: it sends a third party's hostname to SSL Labs just as an API call would, merely performed by the user's machine. So the default remains "print the URL". --open must also degrade gracefully where there is no browser to open: detect the absence (Desktop.isDesktopSupported () / Desktop.Action.BROWSE, or the platform open / xdg-open / start) and fall back to printing rather than failing, since CI is exactly where this command runs unattended.
hideResults=on by default accepted SSL Labs publishes results on public boards unless told otherwise, and hideResults=on suppresses that โ€” confirmed in practice. Submitting a customer's SMP or AP to a public scoreboard without asking is a genuine discourtesy, and an operator may reasonably not want their configuration listed, so the CLI sets it every time. Decided: no flag to omit it. Anyone who genuinely wants a public result can remove the parameter from the printed URL themselves, so the flag would add a way to publish a third party's configuration by mistake while saving nobody any real effort. The CLI should still state before launching that the hostname is being submitted to a third-party service โ€” hiding the result from the boards is not the same as not sending it.
latest to avoid forcing a fresh scan accepted Appending latest requests the most recent assessment rather than starting a new one. Worth defaulting to, for two reasons: a full scan takes minutes and loads a free third-party service, and a recent cached grade already answers "are we still grade A?". Since the CLI is only opening a page, the user is sitting in front of the result and can trigger a rescan themselves if they need a current one โ€” which is a better division of labour than the CLI deciding to spend someone else's compute. Checked: the parameter is not publicly documented. Qualys documents the cache controls for the API (fromCache, startNew in the SSL Labs API documentation), not the query parameters of the interactive analyze.html page, and no published material states what valueless latest does when no cached assessment exists. It demonstrably works for the cached case in practice. The design consequence is to not promise behaviour the project cannot cite: the CLI prints the URL with latest and describes it as requesting the most recent assessment, without asserting what happens when there is none โ€” the user is in front of the page and will see. Settling it definitively would mean scanning a never-assessed host to watch what happens, which is precisely the third-party load this command was designed to avoid.
SSL Labs tests port 443 only A limit worth stating. accepted The d parameter takes a host, not a URL, so the CLI extracts the hostname โ€” and SSL Labs then tests the standard HTTPS port. That aligns with SMP 1.4.0, which mandates port 443, so SMP endpoints are always testable. An AP endpoint published on another port cannot be checked this way, and the CLI should say so rather than offering a link that will analyse a different service than the one in question.
Scope of the policy Which endpoints it applies to. accepted Explicitly: all Access Points for all transport protocols, all SML and Peppol Directory instances, and SMP instances when operated over https. So peppol endpoint tls is meaningful against essentially every URL this tool touches, not just APs.
The TLS certificate is not the Peppol certificate A distinction users confuse constantly. accepted The policy is explicit: "The TLS Certificates are not provided by OpenPeppol and MUST be issued by third party Certificate Authorities." So an endpoint has two unrelated certificates โ€” a public WebPKI certificate for the TLS layer, and a Peppol AP/SMP certificate from the Peppol PKI at the application layer. peppol endpoint tls checks the former, peppol cert verify the latter. The output of both should name which one it is looking at, since "the certificate is fine / invalid" is ambiguous otherwise.
peppol endpoint http <url> Check reachability: status code, redirect chain, response headers, timing. accepted ph-httpclient, consistent with the rest of the ecosystem.
peppol endpoint check <participant-id> <doctype-id> Resolve the endpoint from the SMP and run the TLS and HTTP checks against it. accepted Composition. Must state clearly that a pass does not prove an AS4 exchange would succeed.

Code lists

Command sketch Description Status Notes / Libraries
peppol codelist list <kind> List document types, processes, participant schemes, transport profiles or SPIS use cases. accepted peppol-id generated enums, which mirror the bundled code list XMLs entry for entry: EPredefinedDocumentTypeIdentifier, EPredefinedProcessIdentifier, EPredefinedParticipantIdentifierScheme, EPredefinedTransportProfileIdentifier, EPredefinedSPISUseCaseIdentifier (the eDEC-maintained Peppol Use Case ID list that SPIS 1.0.0 promised); lookups via PredefinedDocumentTypeIdentifierManager, PredefinedProcessIdentifierManager, PeppolParticipantIdentifierSchemeManager. Counts in code list v9.7, read from the bundled XMLs: document types 321, processes 109, participant schemes 105, transport profiles 5, SPIS use cases 1. Each generated enum also exposes its own CODE_LIST_ENTRY_COUNT, so the CLI can report the count without walking the values.
Counts are incidental, not structural accepted Every kind is handled identically regardless of how many entries it happens to hold โ€” SPIS use cases currently has one, transport profiles one active, document types 321. Those numbers will change, so no command, filter or output format should special-case a kind by its size. A kind with a single entry today is simply a kind.
Entry state is three-valued, not two
peppol codelist list transport-profiles accepted Worth knowing when designing the output: only one transport profile is active in v9.7 (AS4, profile version 2.0); START, AS2 1.0, AS2 2.0 and AS4 1.0 are all removed.
peppol codelist search <text> Search across kinds by ID, name or fragment. accepted Same enums. Purely local, so it is instant and works offline.
peppol codelist show <id> Show one entry in full, including its deprecation state. accepted ECodeListItemState / ICodeListItem in edelivery-id.
peppol codelist alias <name> Resolve a code list display name to its full identifier โ€” so a user can type a short, memorable name instead of a 200-character document type ID. accepted Display names are available: EPredefinedDocumentTypeIdentifier.getCommonName() returns e.g. "Virtual Company Dossier", and the code list XML carries them as the name attribute. But see the ambiguity finding below โ€” display names are not unique.
Ambiguous alias fails, in every codelist action too codelist show, codelist alias and anything else taking an alias. accepted If an alias resolves to more than one identifier โ€” unlikely but real, since 16 display names in v9.7 are duplicated โ€” the command fails with exit 2 and the message "alias is not unique", listing each candidate in full and telling the user to pass the full identifier instead. It never resolves to the first match. See CLI Conventions for the JSON shape (ALIAS_NOT_UNIQUE plus the candidate list).
Aliases accepted wherever a document type ID is Any command taking a document type ID also accepts an alias, so peppol smp can-receive 0088:123 "peppol-bis-invoice-3" works. accepted Slugify the display name (lowercase, spaces and punctuation to hyphens) so it needs no shell quoting. Verified constraint: of the 321 document type display names in v9.7, 16 are used twice โ€” for example A-NZ PINT Invoice v1.0, JP PINT Invoice v1.0 and PEPPOL Billing profile Invoice V1 each appear on two entries, typically a busdox-docid-qns and a peppol-doctype-wildcard variant. So an alias must not silently resolve to one of two candidates. Required behaviour: on ambiguity, exit 2 and list the candidates with a qualified form (e.g. --scheme peppol-doctype-wildcard) to disambiguate.
Alias resolution is always echoed When an alias resolves, the full identifier used is printed (stderr in text mode, an explicit field in JSON). accepted Non-negotiable for scripting: a log line saying "checked A-NZ PINT Invoice v1.0" is not auditable, whereas one naming the resolved ID is. It also teaches users the real identifier.
peppol codelist deprecated List every deprecated entry, optionally filtered by kind. accepted isSchemeWithISO6523CodeDeprecated and the per-entry state.
peppol codelist version Print the bundled code list version per kind. accepted Each code list is versioned independently and each generated enum carries its own CODE_LIST_VERSION constant (all "9.7" at the time of writing, but they need not match). This is by design, so the command reports one version per kind and must not treat differing versions as an error. A single aggregate "the code list version" would be a misrepresentation.
Offline by construction Every code list command works with no network at all. accepted Free, and unconditional: the enums are compiled in, so there is no cache to invalidate, no download to fail and nothing for --no-cache to affect in this area. This follows from rejecting codelist update and codelist diff โ€” see Rejected features.
Transport profile source โ€” decided accepted Use EPredefinedTransportProfileIdentifier, generated from the Peppol code list: it is what Peppol allows, so it backs codelist list transport-profiles and every "is this valid in Peppol?" check. ESMPTransportProfile in peppol-commons is a wider collection used internally by the SMP client and is not a statement about Peppol validity, so the CLI must never present it as one.

Validation

Via a configured Phorm instance โ€” see ADR-003.

A published client library now exists: com.helger:phorm-client ("Java client library for the phorm REST API"), so the CLI writes no Phorm protocol code. PhormClient is AutoCloseable, is constructed either with (String sBaseURL, String sToken) or through PhormClient.builder (), and exposes an IIdentifierFactory. Errors surface as PhormClientException.

Command sketch Description Status Notes / Libraries
peppol validate file <doc> Validate a document, letting the server detect the document type. accepted PhormClient.determineAndValidate (byte[]) or (IReadableResource) โ†’ PhormValidationResult.
peppol validate file <doc> --vesid <id> Validate against an explicitly chosen artefact set. accepted PhormClient.validate (DVRCoordinate, ...), or the validate (String sVESID, ...) overload that parses the coordinate for you. A VESID is groupId:artifactId:version[:classifier], where the version may be a pseudo-version such as latest, latest-release or latest-active (ph-diver DVRCoordinate). Example: eu.peppol.bis3:invoice:latest-active.
peppol validate detect <doc> Detect syntax, Customization ID, process, sender/receiver and the derived VESID without validating. accepted PhormClient.determineDocumentDetails (byte[]) โ†’ DocumentDetails (the ddd-model type), so the CLI gets the detection result as a typed object. Note this is the server-side detection endpoint. For a genuinely offline detection, embed ddd-model directly โ€” it is already a compile dependency of phorm-client, so it costs nothing extra. Decided: offer both โ€” --local performs the detection on this machine. The ddd library is light enough to depend on directly, and detection then costs no network call and discloses nothing, which is the honest way to show what would be sent before anything leaves the machine. Note this needs the ddd artifact, not only ddd-model: ddd-model carries the result type DocumentDetails, while the engine DocumentDetailsDeterminator lives in ddd. Construction is new DocumentDetailsDeterminator (DDDSyntaxList.getDefaultSyntaxList (), DDDValueProviderList.getDefaultValueProviderList ()).addDefaultUnwrappers () โ€” the default unwrappers being the SBDH and XHE ones, so a wrapped document is handled without the caller unwrapping it first โ€” then findDocumentDetails (Element), which returns null when the type cannot be determined. DocumentDetails also has getAsJson () and getAllWrappers (), so --output json can embed the library's own structure and the output can state which envelopes were unwrapped. The two modes must be visibly distinguishable in the output, since a local result and a server result can legitimately differ when the instance runs a different ddd version.
peppol validate vesids List the artefact sets the configured instance offers. accepted PhormClient.getAllVESIDs () / getAllVESIDs (boolean bIncludeDeprecated) โ†’ ICommonsList <PhormVESID>. Also the cheapest connectivity probe, since the server does not token-protect this endpoint.
--output text report Human-readable report: counts by severity, then each finding with location, rule ID and message. accepted PhormValidationResult gives everything needed without touching raw JSON: isSuccess (), isInterrupted (), getMostSevereErrorLevel (), getOverallValidity () (EExtendedValidity), getAllErrors () / getAllFailures () (ErrorList), getValidationResultList () (the phive type), getDocumentDetails (), getInvocationDateTime () and getInvocationDurationMillis ().
--output json report Machine-readable report for CI. accepted Decision now easy: PhormValidationResult.getAsJson () returns an IJsonObject, so Phorm's own JSON can be embedded in the result field of the CLI envelope unchanged, with no second shape to maintain. getRawResponse () is available if the untouched body is wanted.
peppol validate file --hybrid <pdf> Validate a hybrid (PDF-carried) document. accepted PhormClient.hybridValidate (byte[]), with overloads taking an EPhormHybridCountry. Lower priority than the XML path.
XML / HTML report passthrough Hand the server's own report straight to the user. accepted validateAsXML / determineAndValidateAsXML โ†’ IMicroDocument (schema: phorm-validation-result.xsd), and validateAsHtml / determineAndValidateAsHtml โ†’ String. Useful for --output xml or for writing a shareable HTML report, which support engineers will want. Decided: yes. The server already renders it, so passing it through costs nothing and gives the one output a human actually wants to read or attach to a ticket. It writes to a file rather than to stdout, since an HTML report is not something to page through in a terminal.
Error and exit code mapping accepted Failures arrive as PhormClientException rather than as raw status codes, which removes the trap noted earlier: Phorm answers HTTP 400 both for an invalid document and for an unparseable body, and the phorm.api.response.onfailure.http400 server setting can turn failures into 200s. The mapping can key off the client's own API โ€” verified. PhormClientException.getErrorType () returns an EPhormErrorType, which has exactly three constants, and _getErrorType (int) derives them purely from the status: >= 500 โ†’ SERVICE_UNAVAILABLE, >= 400 โ†’ REQUEST_ERROR, anything else โ†’ RESPONSE_ERROR. The type alone is therefore not sufficient, because REQUEST_ERROR covers the whole 4xx range โ€” a wrong X-Token and an unparseable document arrive as the same constant. The second discriminator is getStatusCode (), which returns the HTTP status or -1 when the exception was raised client-side without any response at all (an unreadable payload stream, a VESID that cannot be parsed or resolved). That gives a complete mapping with nothing left to message parsing: a parsed report with findings โ†’ 4; SERVICE_UNAVAILABLE โ†’ 5; REQUEST_ERROR with 401 or 403 โ†’ 7, as the exit code table already specifies for Phorm; REQUEST_ERROR with any other 4xx, or with -1 โ†’ 2; RESPONSE_ERROR โ†’ 1, since a usable transport that produced an uninterpretable answer means the client or the server is wrong and a bug report is the right outcome. hasResponse () / getResponse () expose the raw PhormRawResponse (getStatusCode, getContentType, getBodyAsString, getBodyAsJsonObject) for the diagnostic detail. Note getServerException () is on PhormValidationResult, not on the exception โ€” it carries the server's own error object as an IJsonObject on a result that was returned rather than thrown.
Configuration accepted Base URL plus the shared-secret token โ€” peppol.cli.phorm.url and peppol.cli.phorm.token in Configuration. The token is the X-Token header value; PhormClient handles the header itself.

MLS

Command sketch Description Status Notes / Libraries
peppol mls parse <file> Parse and pretty-print an MLS response: response code, status reason codes, referenced message, line-level detail. accepted peppol-commons peppol-mls: PeppolMLSMarshaller, EPeppolMLSResponseCode, EPeppolMLSStatusReasonCode, CPeppolMLS. Built on ph-ubl21.
peppol mls validate <file> Validate an MLS document against its Schematron rules. accepted PeppolMLSValidator (Schematron via ph-schematron-xslt, phive-api).
peppol mls generate โ€ฆ Generate an MLS response. deferred There is no present need, so this is not planned. The builders exist (PeppolMLSBuilder, PeppolMLSLineResponseBuilder, PeppolMLSLineResponseResponseBuilder), so it stays cheap to add if a concrete use case appears โ€” most plausibly an implementer testing their own MLS handling. The reason not to lead with it: an MLS response is a statement by the receiving party about a message it processed, and a CLI that emits one invites hand-crafted statuses being sent outside the AP that actually handled the message. See Open Questions #4.

SBDH

The Standard Business Document Header is the envelope a Peppol business document travels in, and in Peppol it is profiled by the Peppol Business Message Envelope specification. Reading one is offline and read-only โ€” it needs nothing from the network โ€” which makes it the cheapest way to answer "what is actually in this message?", a question that comes up whenever a transmission is disputed. It also closes the loop with the identifier commands: the envelope is where a participant, document type and process identifier are seen together as they were actually sent.

Command sketch Description Status Notes / Libraries
peppol sbdh parse <file> Parse an SBDH envelope and display its fields: sender, receiver, document type, process, instance identifier, creation date and time, the COUNTRY_C1 and MLS scopes, any additional attributes, and whether the payload is non-XML. Peppol constraint violations are reported as part of the output. accepted peppol-sbdh: PeppolSBDHDataReader.extractData(โ€ฆ) โ†’ PeppolSBDHData, with EPeppolSBDHDataError naming each violation. validateData(โ€ฆ) fills an ErrorList, so every violation can be reported in one run rather than only the first โ€” preferable for a diagnostic tool. CPeppolSBDH holds the scope constants (DOCUMENTID, PROCESSID, COUNTRY_C1, MLS_TO, MLS_TYPE) and the expected HEADER_VERSION. Decided: yes, a lenient mode via extractDataUnchecked (โ€ฆ). Refusing to display the one envelope a user is trying to debug would make the command useless exactly when it is needed. Strict remains the default, and lenient output must state plainly that the envelope violates the Peppol rules and list the violations, so "it printed fine" is never mistaken for "it is valid".

Bulk

Command sketch Description Status Notes / Libraries
peppol bulk lookup <file.csv> Run any lookup command over a CSV of participants, writing one result row per input row. accepted ph-csv for reading and writing. Must include the input row's identity in every output row so results can be joined back.
peppol bulk can-receive <file.csv> --doctype <id> The most useful bulk case: check a whole customer list against one document type. accepted Composition of smp can-receive.
peppol bulk cert-expiry <file.csv> Check certificate expiry across many participants โ€” the natural monitoring job. accepted Composition.
peppol bulk sml-participant-add / -remove <file.csv> Write operations, one call per row. accepted Extends bulk beyond lookups, which is a deliberate widening of the area rather than an incidental one. Its value is precisely what the SML's own batch calls cannot offer: a per-row result, so a failed row is identified instead of the whole batch failing opaquely. Use the batch form when you want one call; use this when you want to know which rows worked.
Write operations keep every safety rail Non-negotiable for a bulk writer. accepted A bulk writer is the easiest way to do large-scale damage with this tool, so the rails from CLI Conventions apply in full and are not weakened by being in bulk: --dry-run, explicit confirmation or --yes, the resolved network named in the prompt, and the row count stated before anything is sent. Decided: a bulk write requires --yes on the command line, and still prompts when stdin is a TTY. The two serve different purposes and only look redundant. The flag is the audit trail: it sits in shell history, the cron entry and the CI job definition, where a y keystroke leaves no trace at all. The prompt is the last chance for a human to notice the wrong file, which is worth keeping precisely because --yes typed by habit is how accidents happen. Skipping the prompt when there is no TTY is what makes the combination work rather than hang: in CI the flag is the whole gate, interactively it is both. Running a bulk write without --yes fails with exit 2 and reports what it would have done, so the missing flag costs a re-run rather than a surprise.
--parallel does not apply to SML writes Different throttling problem. accepted --parallel buckets per resolved SMP, which is meaningless here: the SML is a single central service, and every call is an authenticated SOAP request to it. So bulk SML operations need their own conservative, SML-specific pacing rather than per-SMP concurrency โ€” and running them serially is the honest default against infrastructure nobody else can throttle for you. The CLI assumes a rate limit exists โ€” recorded as an assumption, not a finding: SML 1.3.0 publishes none, and nothing in the specification rules one out either. Designing as though there is one costs almost nothing and is the only safe asymmetry, since the failure mode of assuming otherwise is an authenticated client hammering the one central service the whole network depends on. In practice that means three things: prefer the batch operations (CreateList / DeleteList) over N single calls, because fewer requests for the same work is the real mitigation rather than slower pacing; treat HTTP 429 and 503 as throttling and back off, honouring Retry-After when present, exactly as the Directory client already does with PDSearchRateLimitException.getRetryAfterSeconds (); and never state a published limit in help text or documentation, because there is none to cite.
--parallel <n> Run lookups concurrently, limited per SMP. accepted Decided: the limit is applied per resolved SMP URL, not globally, with a default of 2. So a CSV spread across 50 SMPs still runs at useful throughput โ€” total in-flight requests can far exceed 2 โ€” while no single SMP ever sees more than 2 concurrent requests from this tool. A global limit would have been both slower across many SMPs and harsher on any one of them, which is the wrong trade in both directions.
--parallel above 4 warns Configurable, but not silently. accepted Any value greater than 4 proceeds and emits a warning, because per-SMP concurrency beyond that is antisocial rather than merely aggressive โ€” an SMP operator has no way to distinguish it from a small attack. Per the general rule in CLI Conventions a warning does not change the exit code, so --parallel 8 still exits 0; the warning carries a stable code in --output json so a CI job can catch it in review. Decided: warn and proceed, with no hard ceiling. The tool is aimed at people running their own infrastructure, who may have a legitimate reason to push harder against their own SMP, and a ceiling would stop them doing something they are entitled to do. The warning carries the point; enforcing it would be the tool substituting its judgement for the operator's about infrastructure the operator owns.
Bucketing requires resolving first An implementation consequence worth planning for. accepted A participant's SMP is only known after DNS resolution, so the work cannot be bucketed until each row has been resolved. That makes the pipeline two-stage: resolve (bounded by its own concurrency, since DNS goes to the resolver and not to any SMP), then group by resolved SMP URL and apply the per-SMP limit. Getting this wrong collapses into a single global limit and quietly loses the benefit.
The Directory has its own throttle Not covered by --parallel. accepted peppol dir search in bulk is limited by the Directory's own rate limiter (HTTP 429 with Retry-After), which is a single shared service rather than per-participant infrastructure. So Directory bulk work needs its own, more conservative concurrency plus Retry-After handling โ€” reusing the per-SMP limit there would be meaningless, since every request goes to the same host.
--continue-on-error Keep going after a failed row and report a summary. accepted Confirmed as the default for bulk. The exit code then reflects whether any row failed. Stopping at the first failure would make the common case โ€” one dead SMP among hundreds of participants โ€” destroy the value of the whole run, and a bulk job's purpose is to tell you which rows are broken, not to stop at the first one.
Output accepted CSV for spreadsheets, and --output jsonl as a format in its own right rather than a different shape of --output json. That keeps the JSON contract literally true โ€” json remains one document with one envelope โ€” while letting the streaming case be streaming: a consumer starts on row 1 while row 50,000 is still being fetched, neither side holds the whole result, and since --continue-on-error is the default a partial run still yields everything that completed. The format name also tells the consumer what to expect, which json quietly changing shape per command would not.
The stream carries the envelope as a header and a trailer What makes it self-describing. accepted The envelope's run-level fields cannot all be known up front: success and exitCode depend on the last row, especially with --continue-on-error. So a jsonl stream is a header line (schemaVersion, command, network), then one line per row, then a trailer line (success, exitCode and the summary counts), each line carrying a discriminator field so a consumer can tell the three kinds apart without positional assumptions. The trailer is what makes a truncated stream detectable โ€” a consumer that reaches end of input without one knows the run died rather than finished clean, which a bare sequence of rows cannot express.

Rejected features

Things considered and deliberately not planned. Kept here rather than deleted, because "why isn't there a command for X?" is a question that will be asked repeatedly, and because a rejection is a decision worth being able to revisit on purpose.

Command sketch Why rejected
peppol codelist diff <v1> <v2> The generated enums are the only runtime API for code list data, so a single JAR exposes exactly one version, and no shared library for runtime code list access is expected. Diffing would mean the CLI carrying its own code list parser plus a download mechanism โ€” disproportionate for the benefit. Anyone needing a diff can compare two releases of peppol-id directly.
peppol codelist update Same root cause: code list data lives in compiled enums and cannot be refreshed at runtime. Code lists are updated by upgrading the tool, which is predictable and means an unattended run never depends on a background download. peppol codelist version still reports what is bundled, per kind. See Open Questions #5.
peppol sml query <a third party's participant ID> Not implementable. SML 1.3.0 has no anonymous read interface โ€” both management interfaces require mTLS with a certificate under the Peppol root CA, and the authenticated identity scopes what may be read. The only public discovery path is DNS, which peppol dns resolve already provides. Own-SMP reads (sml smp read, sml participant list) are available and are listed under SML.
peppol mlr parse (Message Level Response) MLR is end of life. Building tooling for a format on its way out would create users for something that is going away, and would invite confusion with MLS, which is the one that matters. peppol-commons still ships a peppol-mlr module for existing consumers; peppol-cli does not use it.
Support for other networks โ€” DBNAlliance, HR eDelivery, generic BDXR SMP deployments (--flavour, --provider) Out of scope: this is a Peppol tool. The libraries happen to support other networks โ€” BDXRClientReadOnly / BDXR2ClientReadOnly for OASIS BDXR SMP 1.0/2.0, DBNAURLProviderSMP for DBNAlliance discovery, and the dbnalliance-* / hredelivery-commons modules โ€” but exposing them would double the surface of nearly every command, double the test matrix, and force every output and error message to be hedged about which network it refers to. The value of this tool is that it answers Peppol questions precisely. See ADR-007.
peppol endpoint tls --ssllabs (fetching the grade via the API) The SSL Labs test is performed manually, through the browser link. Not a capability limit: the v3 Assessment API needs no registration and would have worked. The reasons are proportion and manners. An assessment is a polling operation that by design takes at least 60 seconds and often minutes, so it cannot live under the normal request timeouts and would need its own waiter, progress reporting and backoff โ€” a lot of machinery for one diagnostic. It also spends a free third party's compute on every run, with per-client concurrency limits that a CLI could trip on behalf of a user who did not realise what they were starting. The printed analyze.html link already answers the interactive case instantly, and leaves the decision to scan with the person.
peppol cert verify --service-domain An AP certificate is valid for any service domain, so there is no per-domain CA set to verify against and the distinction would be meaningless. peppol-commons contains EPeppolServiceDomain with per-domain CA checkers (POST_AWARD, PRE_AWARD, ENHANCED_B2B), but that separation was never put into practice โ€” so offering the flag would invite users to believe a certificate is domain-scoped when it is not. Role (AP or SMP) and network (test or prod) remain the two dimensions that genuinely select a CA set.
AS4 send / receive, AS4 message inspection, mock AP Out of scope for the project, not just unimplemented. See ADR-001 and Vision and Scope.
TSR / EUSR report submission Out of scope โ€” needs the AP's own message history and identity. See ADR-002.

Two entries elsewhere are not rejected but are worth knowing about, since they are commonly assumed to exist:

  • peppol mls generate is deferred, not rejected โ€” there is no present need. The builders exist, so it can be added whenever one appears. See Open Questions #4.
  • SMP write commands are proposed but not standardised โ€” Peppol SMP 1.4.0 defines no create/update/delete interface, so they target a de-facto convention. See Open Questions #10.
โš ๏ธ **GitHub.com Fallback** โš ๏ธ