Rails 7 API-only service implementing the Digital Product Passport endpoints defined by the CEN/CENELEC JTC 24 standards. The REST contract is derived from EN 18222:2026 (APIs for product passport lifecycle management), with payloads from EN 18223:2026 (system interoperability / semantic model).
ℹ️ Public snapshot. This repository is a snapshot of an internal development repository, published without history and refreshed periodically. Pull requests here cannot be merged — please open an issue instead, or contact OwnYourData.
⚠️ Six of the eight JTC 24 standards were published as EN …:2026 and are cited in the Official Journal; EN 18239 and EN 18246 are still drafts and are cited asprEN. The payload, the method set and the identifier schemes here follow the published editions. Two deviations are deliberate and named where they occur: the element path is a path of element identifiers rather than RFC 9535 JSONPath (EN 18222:2026 8.1), and the two collection operations are an addition beyond the published method set. ThesemanticIdbase URIhttps://jtc24/...is still a placeholder.
docs/Standalone.md is the guide — how to set the service up so it stores Digital Product Passports in its own database and runs without any external infrastructure beyond a PostgreSQL server.
bundle install
bin/rails db:create db:migrate db:seed
bin/rails serverSwagger UI at http://localhost:3000/api-docs, rendered from the OpenAPI 3.0
description in docs/openapi.yaml.
OwnYourData operates a public instance at https://dpp-service.ownyourdata.eu. That deployment does not keep passport documents in its own database: it stores them in a hosting pod operated by the data intermediary DID FlexCo, which then also serves them publicly. The service keeps only an index and the storage configuration.
That mode is out of scope for this snapshot — it needs a pod provisioned by the intermediary and credentials issued by them. Everything documented here is the stand-alone path, which is fully functional on its own.
The hosted instance runs with DPP_AUTH_MODE=did: writing requires a bearer
token that is a self-issued JWT, signed with the document key of the issuer's
did:oyd, and only the DID that created a passport may change it. Reading is
public and needs no token. A stand-alone instance starts in permissive mode,
where the signature is not checked — see docs/Standalone.md.
did:oyd is one of the DID methods EN 18219:2026 admits, specified in
the OYD DID Method and implemented
by the oydid gem and CLI
(source). The identifier is the hash of
its own DID document, so it is self-certifying: what the specification calls
calculate_hash and retrieve_log is what oydid read --show-verification
recomputes step by step. The service uses it for the passport identifier and for
actor identities; nothing in the API is specific to it, and docs/Identifiers.md
says which identifier is which.
Two demonstrations of the same lifecycle, one from each side. Start with whichever matches how you would use it.
| Demo | What it shows |
|---|---|
| docs/DPP-Service-Demo.md | the API, on the command line — a passport created, corrected, handed to a custodian and ended, as curl calls to paste |
| docs/DPP-Manager-Demo.md | the application, with screenshots — the same lifecycle as an economic operator sees it, in the DPP Manager on their own computer |
The rest:
| File | What it covers |
|---|---|
| docs/Standalone.md | running the service on your own, without the intermediary's pod |
| docs/EXAMPLES.md | the walkthrough: issuing a token, then every endpoint as copy-paste curl calls |
| docs/examples-lightbulb.md | one complete worked example (an LED lamp) with full request and response payloads |
| docs/Identifiers.md | the six things called "identifier" in the DPP context, how they relate, and which of them ends up on the data carrier |
| docs/Integrity.md | how the identifier, the version chain and the payload commitment fit together, and what a reader can verify without trusting anyone |
| docs/AccessClasses.md | the vocabulary of access classes (public, legitimate interest, authority), the access policy that assigns one class to every attribute of a passport, how a policy is registered and attached to a passport, access grants and revoking them, what each reader gets on the read paths, and the access log |
| bin/verify_dpp | scan a carrier link, resolve the passport, recompute the hash: bundle exec ruby bin/verify_dpp <carrier URL> |
| bin/attest_payload | the holder side of the same coin: hash what the custodian delivers and sign the entry with your own document key: bundle exec ruby bin/attest_payload <carrier URL> --keys <file> |
| docs/verification/ | the record of the run that moved a live passport between two custodians without touching the printed carrier |
| docs/openapi.yaml | the machine-readable contract: an OpenAPI 3.0 description of every endpoint, which the service also serves through a Swagger UI at /api-docs |
| Method (standard) | HTTP | Path |
|---|---|---|
| ReadDPPById | GET | /dpp/v1/dpps/:dpp_id |
| CreateDPP | POST | /dpp/v1/dpps |
| UpdateDPPById (RFC 7396) | PATCH | /dpp/v1/dpps/:dpp_id |
| DeleteDPPById | DELETE | /dpp/v1/dpps/:dpp_id |
| ReadDPPByProductId | GET | /dpp/v1/dppsByProductId/:product_id |
| ReadDPPVersionByIdAndDate | GET | /dpp/v1/dppsByIdAndDate/:dpp_id?date= |
| ReadDPPIdsByProductIds | POST | /dpp/v1/dppsByProductIds |
| RegisterProductDPP | POST | /dpp/v1/registerDPP |
| ReadDataElement | GET | /dpp/v1/dpps/:dpp_id/elements/*element_path |
| UpdateDataElement | PATCH | /dpp/v1/dpps/:dpp_id/elements/*element_path |
Beyond the standard's methods:
| HTTP | Path | |
|---|---|---|
| change of custodian | POST | /dpp/v1/dpps/:dpp_id/custody |
| read the mandate held for a passport | GET | /dpp/v1/dpps/:dpp_id/delegation |
| hand over a fresh mandate | POST | /dpp/v1/dpps/:dpp_id/delegation |
| service discovery (service DID, audience, reader DIDs, authority issuers) | GET | /.well-known/dpp-service |
| read a collection directly | GET | /dpp/v1/dpps/:dpp_id/collections/:element_id |
| update a collection directly | PATCH | /dpp/v1/dpps/:dpp_id/collections/:element_id |
| register the access policy of a product type | PUT | /dpp/v1/access-policies |
| list the gaps of a passport's access policy | GET | /dpp/v1/dpps/:dpp_id/access-policy |
| register an access grant | PUT | /dpp/v1/access-grants |
| list the access grants one has issued | GET | /dpp/v1/access-grants |
| revoke an access grant one has issued | DELETE | /dpp/v1/access-grants/:jti |
| read the entries of the access log that concern one | GET | /dpp/v1/access-log |
A collection is a subclass of DataElement (EN 18223:2026 4.1.2.4) and is reachable through the element path, so EN 18222:2026 Table 18 defines ReadDataElement and UpdateDataElement only. The two collection operations exist because addressing a top-level collection by name is convenient, not because the standard asks for them.
custody moves a pod-backed passport to the custodian named by a new
X-DPP-Storage mandate. The identifiers do not change, which is the point: the
product identifier is what was printed. The previous custodian keeps serving
unless release_previous=true is passed, because retiring it is bounded by the
DNS time-to-live in the operator's zone. docs/verification/ records a run of
this against two live deployments.
delegation concerns the mandate under which the service reaches the
custodian: it has a lifetime, the passport does not. GET says which mandate is
held, POST replaces it with a fresh one; neither moves the passport. The rules
are in docs/Delegation.md. /.well-known/dpp-service
names the DID an economic operator delegates to, and for each pod the reader
DID under which the intermediary registers the service as protected statutory
reader.
Identifiers used as a single path segment (:dpp_id, :product_id) must be
URL-encoded by the client. *element_path is a glob holding an absolute
element path: the elementId of each level, separated by /.
| Standard | Topic | Where in the app |
|---|---|---|
| EN 18222:2026 | API methods, status codes, Result object | routes, app/controllers, ApiStatus |
| EN 18223:2026 | DPP semantic model (payload) | app/models/dpp.rb, docs/openapi.yaml |
| EN 18216:2026 | Transport and formats (TLS, JSON) | force_ssl; TLS 1.2 and 1.3 at the ingress; response content types |
| prEN 18239 | Access rights and security | TokenAuthenticatable; rate limit in ApplicationController; cross-origin policy in config/initializers/cors.rb; access classes and policies in AccessClass, AccessPolicy, config/vocab/; access grants in AccessGrant; reading per role in PassportAccess; access log in AccessLog |
| EN 18221:2026 | Storage, archiving, versioning | DppVersion, Dpp#archive_current_version! |
| EN 18219:2026 | Unique identifiers, W3C DID | DidOyd, did:oyd and did:web |
| prEN 18246 | Authenticity and integrity | DID document as anchor, PayloadCommitment, PayloadIntegrity, bin/verify_dpp |
| DIN DKE SPEC 99100 | Battery passport attributes | db/seeds.rb example content |
Errors return a Result object (statusCode plus message[]) per EN 18222:2026
Tables 12–15, mapped in app/controllers/concerns/api_status.rb.
mkdir -p storage
RAILS_ENV=test bin/rails db:prepare
bundle exec rspec- Access log kept inside the database (prEN 18239). Signature
verification, owner binding, access classes, per-attribute access policies,
access grants, revoking them and reading per role are implemented, and every
access to the controlled part is logged
(docs/AccessClasses.md). Entries are only ever
added and PostgreSQL refuses to change them, but the log is not anchored
outside the database: someone who can change the database directly can
change it unnoticed. Grants for
authorityare taken only from the issuers listed inDPP_AUTHORITY_ISSUERS. - EU registry client (EN 18222:2026 §5).
registerDPPchecks the entry and answers502(registry_not_connected); nothing is registered. The Commission publishes no API specification for the EU DPP Registry. - Content negotiation (EN 18216:2026 §5). JSON is implemented; XML, JSON-LD and HTML renderers are not.
- Data dictionary (EN 18223:2026 §4.3). The element-path resolver walks the document structurally instead of validating against a product-group dictionary.
- Data integrity (prEN 18246). The content is bound to the identifier:
every change appends a signed log entry carrying
payloadHash, the hash of the payload as the custodian delivers it, andbin/verify_dppchecks that from the public web alone. Where the holder keeps the document key, the service can only report the expected hash and leave the passportpending— it cannot sign in their place. The holder does that themselves, withbin/attest_payloador the DPP Manager, and the service picks the change up when the passport is next read. What remains open is the trust anchor: it is self-managed, 4.8.2 and 5.2.2 ask for an accredited one, so electronic attestation of attributes (EAA) is not covered.
CITATION.cff in the repository root carries the metadata; each release is
archived on Zenodo under a concept DOI that always resolves to the latest
version.