DDPP
Language: EN
Create account

DDPP API: integration guide

Updated 24 October 2026: a passport card becomes the passport its product needs (section 2). On 14 October 2026: gift passport cards (section 2). On 11 October 2026: DDPP's checks of businesses, permanent passports, certificates and analysis reports (sections 1 and 2). 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": "…" } ],
                "review_state": null } ],
  "certifications": [ { "scheme": "…", "number": "…", "issuer": "…",
                        "valid_from": "…", "valid_until": "…", "expired": false,
                        "document_url": "…", "declared_by": "manufacturer",
                        "review_state": "awaiting" } ],
  "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, checkout and in-app purchases 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, item_slots, pricing by level, the web price list, history with each spend's details, and store: what the app may sell), POST …/credits/checkout (pack → a Stripe Checkout url; owners and admins)
In-app purchasesPOST /api/store/apple (signed_transaction, the StoreKit 2 JWS; optional organization_id), POST /api/store/google (product_id, purchase_token; optional organization_id) → {already_granted, product_id, credits, organization, balance} (owners and admins of the organization the purchase was made for)
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}
GuideGET /api/groups (every product group, each with guide: {level, basis, asks}), GET /api/organizations/{id}/guide ({cost}: what its permanent passport takes, as …/passports/cost?level=model gives for a product), POST /api/organizations/{id}/guide (a product and its permanent passport together: product_group, name, brand?, model_number?, gtin?, expected_credits → {product, passport}; both refused together)
Product dataGET /api/products/{id}/editor, PUT /api/products/{id}/data, GET …/revisions
CertificationsPOST /api/products/{id}/certifications, PATCH/DELETE /api/certifications/{id}
Business checkGET /api/organizations/{id}/verification (any member: state, reason, requirements, missing, document_kinds, the VIES answer in vat; owners and admins also get documents and can_submit), POST …/verification/documents (multipart kind, file), GET/DELETE …/verification/documents/{document}, POST …/verification/submit (owners and admins; people only)
PassportsGET /api/organizations/{id}/passports, GET /api/products/{id}/passports/cost?level= (what making one takes: a permanent passport's credits; &card= with a gift passport card), POST /api/products/{id}/passports (level, lot, serial, a batch's quantity and unit, and expected_credits for a permanent passport; card and card_uid to set a gift passport card up with it), GET/PATCH/DELETE /api/passports/{id} (DELETE answers refunded: the credits given back)
Gift passport cardsGET /api/cards/{code} (→ {card: {code, state, gift, gifted, gift_until, setup_until, passport}}), GET /api/cards/{code}/plan?product= (what it becomes on a product), POST /api/cards/{code}/setup; see below
ReviewGET /api/passports/{id}/review (a permanent passport: state, reason, can_submit, blocked_by, missing, and the cost of submitting), POST /api/passports/{id}/submit (expected_credits?)
BatchesGET /api/passports/{model}/batches/new (what the form starts from: the next lot, the last batch's quantity, unit and destination, the fields a batch asks for), POST /api/passports/{model}/batches (lot, quantity, unit, values? → a batch draft under the permanent passport)
Passport dataGET /api/passports/{id}/editor, PUT /api/passports/{id}/data, GET …/revisions, GET …/readiness
Item passports by the runUnder a published batch: POST /api/passports/{batch}/items (serials: [...], or range: {prefix?, from, to, width?} → {created, first, last}: drafts, all or none, at most 1,000 a run), POST …/items/import (multipart file: a CSV whose first row names serial and any of the passport's own fields by key; comma, semicolon or tab; UTF-8), GET …/items/cost ({drafts, published, room, next_run, credits, balance, item_slots, charging}), POST …/items/publish (expected_credits from the cost → {published, remaining, stopped}; 250 a run; the run stops at the first refusal and says why)
Access to what is not publicOwners and admins, people only: GET/POST /api/organizations/{id}/access (email, audience: interested or authority, purpose, product_id?, expires_at? as YYYY-MM-DD), POST …/access/{grant}/revoke (making grants answers 403 access_closed until DDPP opens it). The person given access, signed in with that confirmed address: GET /api/shared (what they hold), GET /api/shared/passports/{public_id} (a published passport's public fields and those their audience may read, each with public and its Annex XIII access group; 404 for anyone else; every reading is recorded)
LifecycleGET /api/passports/{id}/cost (what the draft's next step takes now — submitting a permanent passport, publishing a batch or an item: step, credits, balance_after, and why), POST /api/passports/{id}/publish (batches and items; expected_credits?: the cost shown, which is then what is taken or nothing; 402 not_enough_credits when charging is on; a permanent passport answers 409 review_required), …/recall (reason?), …/withdraw (owners and admins)
EventsGET/POST /api/passports/{id}/events, PATCH/DELETE /api/events/{id}, POST /api/events/{id}/media
NFC chipsGET /api/passports/{id}/chips, POST …/chips/check (uid → {already, locked}), POST …/chips (uid, kind: tag or card, locked? → 201 new, 200 the same chip again)
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)
App StorePOST /api/store/apple/notifications (App Store Server Notifications V2: Apple alone, by its signature)
HealthGET /api/health

How the data model behaves

Gift passport cards

Since 14 October 2026 DDPP hands out NFC cards whose chip and printed QR code carry https://ddppeu.com/c/{code}. The code is ten characters of Crockford base32, read in any case, with or without spaces and hyphens, and with I or L for 1 and O for 0. A business sets a card up on one of its products, and the card becomes the passport that product needs; from then on the card's link leads to that passport.

GET /api/cards/{code}

Any signed-in member may ask:

{ "card": { "code": "7KQ2M9XD10",
            "state": "available",       // available | yours | taken | expired
            "gift": true,               // would setting it up now make the permanent passport free
            "gifted": null,             // yours only: whether it was set up as a gift
            "gift_until": "2026-11-22", // days in Europe/Istanbul, inclusive; null: no gift
            "setup_until": "2026-12-31", // null: no end
            "passport": null } }        // yours only: { id, public_id, product_id, organization_id, level }

``jsonc { "plan": { "becomes": "batch", // model | batch | item | existing "level": "batch", // the product's level (below) "product": { "id": 12, "name": "Linen shirt" }, "passport": { "id": 40, "public_id": "…", "status": "published" }, // its permanent passport, once it has one "lot": "LS-02", "quantity": "200", "unit": "pcs", // a lot: what it starts from (the next after the last) "lots": [], // a unit: the published lots to choose from, the latest first ("lot" is the latest) "cost": null } } // a permanent passport: its quote (as …/passports/cost); null where nothing is charged now ``

- model: the product has no permanent passport, and the card makes it (the gift applies). - existing: the product's level is model (its permanent passport covers every unit), so the card leads to that passport, whatever its state. Nothing is made or charged, and a passport can have several cards this way. - batch or item: a new lot's passport, or one unit's under a published lot, made as a draft with the card under the approved permanent passport. It costs what any lot or unit costs when it is published; it is never a gift. - Refused: card_wait_for_review (409: the permanent passport is a draft or in review), card_batch_needed (409: a unit needs a published lot), card_passport_rejected (409: DDPP rejected it; delete its draft first), card_passport_withdrawn (409), model_required (409: it is recalled), and card_taken or card_expired as above. - The product's level is the law's where its product group's law sets one; otherwise the guide's answer (varies on POST /api/organizations/{id}/guide: unit, run or none), then what the product already has (unit passports, then lots), then the group's suggestion. - Setting it up: POST /api/cards/{code}/setup with product, the becomes the plan said, optionally card_uid (the chip's UID in hex as the phone read it, 4 to 10 bytes, colons allowed) and what it becomes asks for: expected_credits (model), lot, quantity and unit (batch), or lot and serial (item). It answers 201 with {passport, card}. A plan that changed meanwhile (a permanent passport made or approved) is refused with 409 card_plan_changed, carrying the fresh plan. The card is set up in the same transaction as the passport, so two people tapping one card set it up once, and any refusal leaves the card as it was. - Before 24 October a card was set up only by making a permanent passport with it, and that still works: POST /api/products/{id}/passports with level: "model", card and optionally card_uid (422 card_level for any other level). - The gift: a card set up on or before its gift_until makes its permanent passport free for good: nothing is charged when it is made, nor when it is submitted, whenever that is. Read the cost first with GET /api/products/{id}/passports/cost?level=model&card={code}: credits is 0 and basis.gift_card true (with basis.gift_until) while the gift is open, the normal price after it, and the same refusals as setting it up; send credits back as expected_credits. The credit history shows the gift as an entry of reason gift_card that moves nothing (delta 0), and a draft's GET /api/passports/{id}/cost shows basis.gift_card too. A card set up after gift_until and on or before setup_until costs the normal price. - Deleting the passport's draft, or DDPP rejecting it, releases its cards: each can be set up again while it is open, and is a gift again only if that happens on or before its gift_until. - The card's own page, GET /c/{code}, needs no account: it leads to the passport once that is public or withdrawn (303 to /p/{public_id}), names the organization preparing it until then, explains how to set an available card up, and answers 410 once a card nobody set up has expired.

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.