DDPP API: integration guide
Base address: https://ddppeu.com. Every link the API writes into a passport, a label or a Digital Link is built on it. https://dppeu.cloud, the address until 23 September 2026, still answers /api/ and sends everything else to the same path on ddppeu.com, so a code printed with it keeps working.
1. What works today with no credentials at all
This is the part an importer, a customer's IT department or a marketplace can use immediately. Reading is anonymous by design — the ESPR expects public passport data to be readable without registering, and the app follows the same rule.
Read a passport
GET /api/public/passports/{public_id}
public_id is a UUID v4. Returns the passport as everyone sees it: never a person, never a member, never an email address. The same payload renders the HTML page and the resolver, so they cannot disagree.
{
"passport": { "id": "…", "level": "batch", "lot": "…", "serial": null,
"status": "published", "published_at": "…", "recalled_at": null,
"recall_reason": null, "url": "https://ddppeu.com/p/…",
"digital_link": "https://ddppeu.com/01/…" },
"product": { "name": "…", "brand": "…", "model_number": "…", "gtin": "…",
"group": "…", "group_label": "…", "image_url": "…" },
"operator": { … the economic operator … },
"classification": { "is_espr_passport": false, "espr_status": "…", "regime": "…" },
"blocks": [ … the schema's blocks, for rendering … ],
"data": { … only what the product group publishes at the public tier … },
"events": [ { "kind": "…", "title": "…", "occurred_at": "…", "place_name": "…",
"latitude": …, "longitude": …,
"media": [ { "url": "…", "sha256": "…", "mime": "…" } ] } ],
"certifications": [ { "scheme": "…", "number": "…", "issuer": "…",
"valid_from": "…", "valid_until": "…", "expired": false,
"document_url": "…", "declared_by": "manufacturer" } ],
"updated_at": "…"
}
Two things worth reading carefully, because they are deliberate:
- **
declared_by: "manufacturer"** on every certification. DDPP does not check certifications, and says so wherever it shows them. Do not render them as a verification. - **
classification.is_espr_passport** decides the words. When it is true the record may be called a Digital Product Passport; otherwise call it a product passport or a product record. The API is the only thing that decides this — do not infer it.
A withdrawn passport answers 410 Gone with code: "withdrawn" and nothing but its id and status — it existed, and it is gone. An unknown id answers 404 with code: "not_found". A draft is not served at all.
Resolve a GS1 Digital Link
GET /01/{gtin}/10/{lot} Accept: text/html → 303 to the passport page
GET /01/{gtin}/10/{lot} Accept: application/json → the payload above
GET /01/{gtin}/10/{lot} Accept: application/linkset+json → an RFC 9264 linkset
GET /01/{gtin}/10/{lot}?linkType=linkset → the same linkset
The code forms are /01/{gtin} (the model), /01/{gtin}/10/{lot} (a batch), /01/{gtin}/21/{serial} (an item) and /01/{gtin}/10/{lot}/21/{serial} (an item, with the lot it came from); each leads to the most specific published or recalled passport, item first, then batch, then model. An item made with its serial alone is found whether or not the label also carries the lot. When more than one organization has entered the same GTIN, the code answers for the one whose GS1 Company Prefix the GTIN falls under, then for the one that published a passport for that code first. Content negotiation follows the GS1 resolver conventions: ?linkType= first, then Accept, best quality first, and every answer carries Vary: Accept. A 303 also carries a Link: …; rel="linkset" header. A withdrawn passport answers 410 Gone, which is the correct answer for a code that was real and is no longer served. The path is parsed raw, so a lot containing an encoded / stays one value.
Search, without a code
GET /api/public/search?q=…
Finds published and recalled passports whose organization has listing switched on, as summaries: id, status, level, lot, serial, url, product name/brand/GTIN/image, the operator's name and country, and published_at. An exact code or one of our links always resolves to that one passport whether or not it is listed — a printed label must not stop working because a business left the search. Results come a page at a time: page and per_page (25 unless asked, at most 50) choose it, and meta gives current_page, last_page, per_page and total; an exact match is a single page of one. The app's Find screen asks for the next page on "Show more".
Being in a result is not a merit and the response says nothing evaluative. The same search backs the /find page and /sitemap-passports.xml.
Public files
GET /api/public/media/{id}
Serves a file only while public content of a published or recalled passport still points at it: evidence on a public event, the product photo, or a public certification's document. Every file carries a SHA-256 of its stored bytes in the payload, so a recipient can check that what they downloaded is what the passport refers to.
Limits
60 requests a minute per IP address on the public API, 300 a minute for public files. The client's real address comes from the proxy protocol; X-Forwarded-For is not trusted.
Signed in (section 2), the ceilings are per account or API client, so one integration cannot spend another's budget: 300 requests a minute (and 600 a minute per IP behind that), 60 writes a minute (every POST, PATCH, PUT and DELETE), 30 uploads a minute, 20 invitations an hour per organization, and 5 account mails an hour. Sign-in and registration have their own small limits. A refused request answers 429 with Retry-After.
2. The member API
Everything a business does — products, passports, data, events, evidence, publishing — is a REST API under /api, authenticated with a bearer token. It is the same API the mobile app uses; there is no second, private one.
Authorization: Bearer {token}
X-Language: de # de, en, tr or es — error messages come back in that language
?lang=de on the URL does the same, for a client that cannot set a header.
Credentials for a system, not a person
An integration authenticates as an API client of the organization: a member nobody signs in as, holding an ordinary membership with a role. The same policies decide what it may do as decide for a person, so its access is the organization's own rules, not a parallel set.
POST /api/organizations/{id}/tokens
{ "name": "SAP connector", "role": "editor" }
201 { "token": "1|xxxxxxxx…", "client": { "id": 7, "name": "SAP connector", "role": "editor",
"expires_at": "2027-09-22T…" } }
- Only an owner or an admin of the organization may create one, and only as
editor(reads and writes) orviewer(reads). A client can never manage the organization, and never mint another client. - The secret is shown once. What the database keeps is a hash; there is no way to display it again. Store it in your own secret store as you receive it.
- The token lasts a year.
GET /api/organizations/{id}/tokenslists every client withlast_used_atandexpires_at, so an owner can see whether an integration is still running and when it must be rotated. - Rotating is revoking and creating another.
DELETE /api/organizations/{id}/tokens/{client}removes the token, the membership and the member at once, and the secret stops working immediately. What the client wrote stays: passports belong to the organization, not to the credential that wrote them. - A client's token is refused by
/api/me, by organization creation and by the client routes themselves, withcode: "service_token"— a leaked integration secret is not a way into anybody's account.
An owner or admin can also do this in a browser: Desk → API clients (/desk/api-clients) makes a client, shows its token once on the page that follows, lists every client with last_used_at and expires_at, and revokes one. It is the same service the endpoints above call (App\Services\ApiClients), so a client made either way is the same thing.
Everything under /api/me, the members, invitations, exports and client routes, organization creation and checkout are for people only: a client's token there answers 403 service_token.
| Area | Endpoints |
|---|---|
| Sign-in | POST /api/auth/register, …/login, …/google, …/apple (→ {token, user, created}), POST /api/auth/forgot-password (always 200), POST /api/auth/logout |
| Account | GET/PATCH/DELETE /api/me, POST /api/me/password (current_password, password; keeps this token, ends every other), POST /api/me/verification |
| Organizations | POST /api/organizations (name, country, optional details and referral_code), GET/PATCH /api/organizations/{id} |
| Members | GET /api/organizations/{id}/members, PATCH/DELETE …/members/{user} (role; remove), DELETE …/membership (leave) |
| Invitations | GET/POST /api/organizations/{id}/invitations, DELETE …/invitations/{invitation}, GET /api/invitations/{token}, POST /api/invitations/{token}/accept |
| API clients | GET/POST /api/organizations/{id}/tokens, DELETE …/tokens/{client} |
| Credits | GET /api/organizations/{id}/credits (balance, price list, history), POST …/credits/checkout (pack → a Stripe Checkout url; owners and admins) |
| Exports | GET/POST /api/organizations/{id}/exports, GET …/exports/{export}/download (owners and admins; a zip, 7 days) |
| Products | GET/POST /api/organizations/{id}/products, GET/PATCH/DELETE /api/products/{id} |
| Product data | GET /api/products/{id}/editor, PUT /api/products/{id}/data, GET …/revisions |
| Certifications | POST /api/products/{id}/certifications, PATCH/DELETE /api/certifications/{id} |
| Passports | GET /api/organizations/{id}/passports, POST /api/products/{id}/passports, GET/PATCH/DELETE /api/passports/{id} |
| Passport data | GET /api/passports/{id}/editor, PUT /api/passports/{id}/data, GET …/revisions, GET …/readiness |
| Lifecycle | POST /api/passports/{id}/publish (402 not_enough_credits when charging is on), …/recall (reason?), …/withdraw (owners and admins) |
| Events | GET/POST /api/passports/{id}/events, PATCH/DELETE /api/events/{id}, POST /api/events/{id}/media |
| Files | POST /api/organizations/{id}/media, GET /api/media/{id} |
| Public | GET /api/public/passports/{public_id}, GET /api/public/search?q=, GET /api/public/media/{id} (section 1) |
| Stripe | POST /api/stripe/webhook (Stripe alone, by its signature) |
| Health | GET /api/health |
How the data model behaves
- An organization is the economic operator a passport names. Members have roles: owners and admins manage it, editors write, viewers read.
- An organization can record the DDPP partner that introduced it:
referral_codeonPOST /api/organizations, the partner's code in any case (one no partner has is a 422 onreferral_code). It is recorded once, as the organization is set up, and never returned or changed afterwards; aPATCHthat sends it changes nothing. In a browser, the partner's linkhttps://ddppeu.com/desk/register?ref=CODEfills it in on the new-organization form. - A product carries what every one of its passports inherits: name, brand, model, GTIN, product group, image, certifications. A GTIN is unique within an organization. Its
gtin_statusisownwhen the GTIN falls under the organization'sgs1_company_prefix(6 to 12 digits, set on the organization; read after the GTIN's indicator digit),claimedwhen it does not, and null without a GTIN. It is the organization's own word, not a check by GS1. - A passport covers a level — model, batch or item — and a batch by default. A batch is named by its lot; an item by its serial, with or without the lot it came from; a model has neither. One passport per product, level, lot and serial.
- Data is append-only. Every save writes a whole new revision and the latest one is current; a
PUTchanges only the keys it sends (nullor""clears one), and a save that changes nothing writes no revision. Nothing is ever updated in place or deleted. - The schema is server-side.
/editorreturns the fields for the product's group, so new EU rules do not need a new app or a new integration. - **
readiness** lists what is still missing before a passport may be published. - Lifecycle: draft → published → recalled → withdrawn. Only a draft can change its lot, serial or GTIN, or be deleted. After publishing, events can only be added and data changes become new revisions. A recall keeps the passport public with a notice, and any editor may make one; a withdrawal ends it, and only owners and admins may. Publishing is what a credit buys once charging is on; everything before it is free.
Errors
HTTP status plus a machine-readable code and a message already translated into the request's language. Validation errors arrive as Laravel's errors map (422). Branch on code, never on message. The codes, by area:
- Signing in and the account:
invalid_credentials(401 on sign-in; 422 on a password change with the wrong current password),no_password(409: the account signs in with Google or Apple),token_not_proven(401),provider_not_configuredandprovider_unreachable(503),email_unverified(401),unverified(403: confirm the address first),nothing_to_verify(409),mail_unavailable(503),service_token(403: an API client's token on a person's route). - Organizations and people:
last_owner(409),needs_owner(403),yourself(409),already_member(409),not_a_member(404),invitation_invalid(404),invitation_not_yours(403),too_many_invitations(429),export_expired(410). - Billing:
not_enough_credits(402, withbalanceandcost),billing_unavailableandbilling_unreachable(503). - Products and passports:
has_passports(409),identifier_locked(409),published(409),not_draft(409),not_ready(422, withmissing),not_published(409),withdrawn(409 on a recall or withdrawal of a withdrawn passport; 410 when reading it),not_found(404).
3. What is not built yet
These are on the roadmap, not in the product.
- No webhooks and no event subscriptions. A client that wants to know when a passport was published has to poll.
- No bulk import. No CSV or Excel import. An export of a whole organization exists (
/api/organizations/{id}/exports: owners and admins, never an API client; a zip of JSON and files, downloadable for 7 days), which the Terms promise free of charge. - No signed passports yet. W3C Verifiable Credentials, EPCIS and EU registry submission are planned, not built.
- No sandbox. There is one environment; a client integrating today works against live data.
4. Worked example: read a passport from a scanned code
# A scanned GS1 Digital Link, asked for as data rather than a page
curl -sH 'Accept: application/json' \
'https://ddppeu.com/01/09506000134352/10/LOT-2026-07' | jq '.passport.status, .product.name'
# The same passport by its public id
curl -s 'https://ddppeu.com/api/public/passports/2b1e…' | jq '.certifications[].declared_by'
# What links exist for this code, for a client that speaks GS1
curl -sH 'Accept: application/linkset+json' \
'https://ddppeu.com/01/09506000134352/10/LOT-2026-07'
No key, no account, no rate-limit negotiation. That is the part of DDPP a partner can integrate against this afternoon.