DDPP
Language: EN
Join the beta

DDPP API: integration guide

Updated 25 September 2026. It describes what DDPP does today, not what is planned; where something is not built, it says so.

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:

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…" } }

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.

AreaEndpoints
Sign-inPOST /api/auth/register, …/login, …/google, …/apple (→ {token, user, created}), POST /api/auth/forgot-password (always 200), POST /api/auth/logout
AccountGET/PATCH/DELETE /api/me, POST /api/me/password (current_password, password; keeps this token, ends every other), POST /api/me/verification
OrganizationsPOST /api/organizations (name, country, optional details and referral_code), GET/PATCH /api/organizations/{id}
MembersGET /api/organizations/{id}/members, PATCH/DELETE …/members/{user} (role; remove), DELETE …/membership (leave)
InvitationsGET/POST /api/organizations/{id}/invitations, DELETE …/invitations/{invitation}, GET /api/invitations/{token}, POST /api/invitations/{token}/accept
API clientsGET/POST /api/organizations/{id}/tokens, DELETE …/tokens/{client}
CreditsGET /api/organizations/{id}/credits (balance, price list, history), POST …/credits/checkout (pack → a Stripe Checkout url; owners and admins)
ExportsGET/POST /api/organizations/{id}/exports, GET …/exports/{export}/download (owners and admins; a zip, 7 days)
ProductsGET/POST /api/organizations/{id}/products, GET/PATCH/DELETE /api/products/{id}
Product dataGET /api/products/{id}/editor, PUT /api/products/{id}/data, GET …/revisions
CertificationsPOST /api/products/{id}/certifications, PATCH/DELETE /api/certifications/{id}
PassportsGET /api/organizations/{id}/passports, POST /api/products/{id}/passports, GET/PATCH/DELETE /api/passports/{id}
Passport dataGET /api/passports/{id}/editor, PUT /api/passports/{id}/data, GET …/revisions, GET …/readiness
LifecyclePOST /api/passports/{id}/publish (402 not_enough_credits when charging is on), …/recall (reason?), …/withdraw (owners and admins)
EventsGET/POST /api/passports/{id}/events, PATCH/DELETE /api/events/{id}, POST /api/events/{id}/media
FilesPOST /api/organizations/{id}/media, GET /api/media/{id}
PublicGET /api/public/passports/{public_id}, GET /api/public/search?q=, GET /api/public/media/{id} (section 1)
StripePOST /api/stripe/webhook (Stripe alone, by its signature)
HealthGET /api/health

How the data model behaves

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:

3. What is not built yet

These are on the roadmap, not in the product.

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.