API-dokumentation
Integrera DPP-Tool i dina befintliga system.
API-åtkomst kräver en Pro-plan eller högre.
Se planerBase URL & conventions
The REST API is served directly from the apex host, with no language prefix:
https://dpp-tool.com/api
- Request and response bodies are JSON (
Content-Type: application/json; charset=utf-8). File uploads usemultipart/form-data; downloads (export, QR, document files) return their native content type. - Do not add a trailing slash to
/api/*paths, and do not append.php. The correct path is/api/products, never/api/products/or/api/products.php. A trailing slash used to 301-redirect, and a 301 on aPOSTis replayed by clients as aGETthat drops the request body — a valid create then came back as a400. The redirect has been removed, but callers should still not add the slash. - Send a real
User-Agentheader. Our host's firewall rejects a handful of default library agents before the request ever reaches the API — notably Python's standardurllib(Python-urllib/3.x), which comes back as a bare403on a perfectly valid call. Identify your integration (User-Agent: AcmeERP/1.0) and the problem disappears.curl,python-requests, Postman and browsers are unaffected. - The public read endpoints (
passport,exportsingle-passport mode,qrcode, and the GS1 resolver) need no key and operate on a published passportslug. Everything else is authenticated. - Timestamps in JSON bodies are either MySQL
DATETIMEstrings (YYYY-MM-DD HH:MM:SS, product rows) or ISO-8601 UTC (YYYY-MM-DDTHH:MM:SSZ, passport and webhook payloads), matching what the source stores.
Authentication
Create an API key from Dashboard → Settings. Send it on every authenticated request, either as a bearer token (recommended) or as an api_key query parameter:
Authorization: Bearer YOUR_API_KEY
https://dpp-tool.com/api/products?api_key=YOUR_API_KEY
When you create a key it appears in a panel with a copy button. Copy it then: only a hash of it is stored, so it can never be shown again, only deleted and re-created.
Key access: read-only or read and write
Every key carries an access level, chosen when you create it and shown in the key list:
- Read only —
GETrequests succeed.POST,PUTandDELETEare refused with403and a body that names the reason:{"error":"This API key is read-only.","scope":"read"}. Use this for dashboards, exports and any integration that must not be able to change your data. - Read and write — every method is allowed, subject to your plan and your role on the account.
You can hold several keys at once, with different access levels, and delete the ones you no longer use.
Keys created before access levels existed are read and write, so nothing you already built changes.
The key cannot be retrieved later, only deleted and re-created. A missing or invalid key returns 401 {"error":"Authentication required"}.
API access is a paid entitlement. A key only works on a plan that grants API access — Pro, Business or Enterprise. On a lower plan (or once a plan is downgraded) authenticated calls to the products, documents, suppliers and export endpoints return 403 {"error":"API access requires Pro plan or higher"}. This applies to reads as well as writes.
Accounts & team roles
Data belongs to an account, not to an individual user. When a team member calls the API with their own key, they read and write the owner's account, under the owner's plan and limits.
- owner / admin — read and write.
- viewer — read only. A write returns
403 {"error":"Your role on this account is read-only"}.
Anything the caller's account does not own is reported as 404, never 403, so the API never confirms the existence of another account's resource.
Rate limits
Rate limits are counted per authenticated user (across all of that user's keys), over a rolling one-hour window, and the budget is set by the account's plan. Counting per user means one team member cannot exhaust the whole team's budget.
| Plan | Requests / hour |
|---|---|
| Free | 60 |
| Starter | 120 |
| Pro | 600 |
| Business | 2000 |
| Enterprise | 5000 |
Because API keys require Pro or higher, the budgets that apply in practice are Pro 600, Business 2000 and Enterprise 5000 requests/hour. Exceeding the limit returns 429 with a Retry-After: 60 header; the caller is then blocked for 60 seconds:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{ "error": "Rate limit exceeded. Please try again later." }
Getting started
- Create an API key in Dashboard → Settings (Pro plan or higher). Copy it — it is shown only once.
- Create a product with
POST /api/products. The response carries the generatedpassport_slug, its publicpassport_urland itsqr_code_url. - Fetch the passport and QR: the passport JSON is public at
GET /api/passport?slug=…, the printable PNG atGET /api/qrcode?slug=…, and a scan of the product's GS1 Digital Link resolves to the passport page. - Subscribe a webhook (Business/Enterprise) in Dashboard → Settings to receive
product.created,product.updatedandimport.completedevents instead of polling.
Endpoints
Auth column: public = no key; API access = a key on a Pro+ plan; writer = an owner/admin role (viewers are read-only) and a key whose access is Read and write.
GET /api/products
List the products of your account, most recent first. Paginated. Auth: API access.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
page | int | 1 | 1-based page number. |
per_page | int | 20 | Items per page. Clamped to the range 1–100. |
cursor | string | — | Opaque keyset cursor. Pass back the next_cursor from the previous response; an invalid cursor returns 400. When present, page is ignored. |
Every response includes a next_cursor: an opaque string for the page after this one, or null at the end. Results are newest-first with a deterministic tie-break, in both modes.
Page mode
curl -s "https://dpp-tool.com/api/products?page=1&per_page=20" \
-H "Authorization: Bearer YOUR_API_KEY"
Response 200
{
"products": [
{
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "EcoCharge Pro 5000",
"sector": "batteries",
"gtin": "4006381333931",
"model": "EC-5000",
"product_slug": "ecocharge-pro-5000-x1y2",
"passport_slug": "ecocharge-pro-5000-a1b2c3",
"created_at": "2026-07-01 10:30:00",
"updated_at": "2026-07-01 10:30:00",
"passport_url": "https://dpp-tool.com/en/passport/ecocharge-pro-5000-a1b2c3/",
"qr_code_url": "https://dpp-tool.com/api/qrcode?slug=ecocharge-pro-5000-a1b2c3"
}
],
"total": 1,
"page": 1,
"per_page": 20,
"next_cursor": null
}
Two slugs, one of them routable. passport_slug is the one every other endpoint takes as ?slug= — /api/passport, /api/qrcode, /api/passport-versions, /api/export. product_slug is the product's own slug and is not routable; it is returned for reference only. passport_url and qr_code_url are given ready-made so you never have to build them. A product whose passport has been archived carries null in all three passport fields.
Page mode also returns an exact total and the page number. It suits a paged UI, but a deep page grows more expensive as the offset grows; for a full catalogue sweep, prefer cursor mode.
Cursor mode — iterate a large catalogue
Start with no cursor, then keep passing the previous next_cursor until it is null. Each page costs the same at any depth, so this scales to hundreds of thousands of products; cursor mode omits total so a full sweep does not run a count on every page. The cursor is opaque — echo it back, never parse or build it.
# First page (no cursor)
curl -s "https://dpp-tool.com/api/products?per_page=100" \
-H "Authorization: Bearer YOUR_API_KEY"
# Each subsequent page: pass the previous next_cursor
curl -s "https://dpp-tool.com/api/products?per_page=100&cursor=NEXT_CURSOR_FROM_PREVIOUS_RESPONSE" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"products": [ "... up to per_page rows ..." ],
"per_page": 100,
"next_cursor": "MjAyNi0wNy0wMSAwOTowMDowMB8xZDBi"
}
GET /api/products/{id}
Fetch a single product with its full record — every field you can write, not the summary the list returns. Auth: API access. The {id} is the product_id; it may also be passed as ?id=.
curl -s "https://dpp-tool.com/api/products/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer YOUR_API_KEY"
Response 200
{
"product": {
"id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"name": "EcoCharge Pro 5000",
"sector": "batteries",
"manufacturer_name": "ACME Industries",
"carbon_footprint": "12.5",
"external_ref": "ERP-4471",
"product_slug": "ecocharge-pro-5000-x1y2",
"passport_slug": "ecocharge-pro-5000-a1b2c3",
"passport_url": "https://dpp-tool.com/en/passport/ecocharge-pro-5000-a1b2c3/",
"qr_code_url": "https://dpp-tool.com/api/qrcode?slug=ecocharge-pro-5000-a1b2c3",
"certifications": "ce_marking,iso_14001,NF EN 442-1",
"certifications_list": [
{ "code": "ce_marking", "label": "CE marking", "group": "conformity", "standardised": true },
{ "code": "iso_14001", "label": "ISO 14001 (environmental management)", "group": "environment", "standardised": true },
{ "code": null, "label": "NF EN 442-1", "group": null, "standardised": false }
]
}
}
A product that is not in your account returns 404, never 403 — the API never confirms the existence of another account's row.
POST /api/products
Create a product. On success a Digital Product Passport is generated automatically and the passport slug, public URL and QR-code URL are returned. Auth: API access + writer.
Body fields
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Product name. |
manufacturer_name | string | yes | Legal manufacturer name. |
sector | string | no | Defaults to other when omitted. If sent, must be one of batteries, textiles, electronics, furniture, construction, other. |
Optional: gtin, model, batch_number, manufacturer_country, carbon_footprint, recycled_content, energy_class, expected_lifetime, warranty_years, repairability_score, recyclability, origin_country, certifications (defined value list — array of codes or comma-separated, see Certifications), material_composition. | |||
A valid gtin (8/12/13/14 digits, mod-10 check digit) unlocks the product's GS1 Digital Link and its QR code. See GS1 Digital Link below. Creating past your plan's product quota returns 403 {"error":"Product limit reached for your plan"}.
Example
curl -s -X POST "https://dpp-tool.com/api/products" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "EcoCharge Pro 5000",
"sector": "batteries",
"manufacturer_name": "GreenTech GmbH",
"gtin": "4006381333931",
"batch_number": "LOT42",
"carbon_footprint": 12.5,
"recycled_content": 65,
"energy_class": "A+"
}'
Response 201
{
"product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"passport_slug": "ecocharge-pro-5000-a1b2c3",
"passport_url": "https://dpp-tool.com/en/passport/ecocharge-pro-5000-a1b2c3/",
"qr_code_url": "https://dpp-tool.com/api/qrcode?slug=ecocharge-pro-5000-a1b2c3"
}
Emits the product.created webhook on plans that have webhooks enabled.
POST /api/products bulk
Create up to 200 products in a single request by sending a JSON array instead of a single object ({"products": [ … ]} is accepted too). Each product is created in its own transaction, and the whole call costs one unit of your rate limit — this is the endpoint to use for a catalogue, not a loop of single creates.
curl -s -X POST "https://dpp-tool.com/api/products" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{"name": "Crew Neck Tee", "sector": "textiles", "manufacturer_name": "Dhaka Apparel Ltd", "gtin": "4006381333931"},
{"name": "Denim Jacket", "sector": "textiles", "manufacturer_name": "Dhaka Apparel Ltd"}
]'
Response — every item is reported by its position in the array you sent, so a partial success is never ambiguous:
{
"created": 2,
"failed": 0,
"skipped": 0,
"results": [
{"index": 0, "status": "created", "product_id": "…", "passport_slug": "crew-neck-tee-a1b2",
"passport_url": "https://dpp-tool.com/en/passport/crew-neck-tee-a1b2/",
"qr_code_url": "https://dpp-tool.com/api/qrcode?slug=crew-neck-tee-a1b2"},
{"index": 1, "status": "created", "product_id": "…", "passport_slug": "denim-jacket-c3d4", "…": "…"}
]
}
| Status | Meaning |
|---|---|
201 | Every product in the array was created. |
207 | Partial success. Read results[]: each entry is created, error (with details) or skipped. The products that succeeded are created — do not resend them. |
400 | Nothing was created (all items invalid, or the array was empty). |
403 | Your plan's product limit was already reached, so nothing was created. |
413 | More than 200 items in one request. Send the catalogue in chunks. |
Reaching the plan limit mid-array is not an error: the products that fit are created, the rest come back as skipped with the reason, and the call returns 207. Nothing is ever silently dropped.
Uploading a spreadsheet instead of calling the API? Dashboard → Import takes a CSV or XLSX of up to 32 MB (about 200 000 rows) and imports it in resumable batches — measured at ~700 rows/second, so a 100 000-SKU catalogue lands in a little over two minutes.
reliability Safe retries & re-sends
Two independent guarantees, both optional, both designed for ERP connectors that run unattended.
1. Idempotency-Key — survive a lost response
If the network drops the response after we have already created your products, your client cannot know whether the call landed. Send an Idempotency-Key header (8–255 printable ASCII, unique per request — a UUID is ideal) and retrying is free:
curl -s -X POST "https://dpp-tool.com/api/products" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: 8f14e45f-ea0f-4b1e-9c2a-2d1c0e4b7a91" \
-H "Content-Type: application/json" \
-d '[{"name": "Crew Neck Tee", "sector": "textiles", "manufacturer_name": "Dhaka Apparel Ltd"}]'
- The first call runs and its response is stored.
- Any retry with the same key and the same body returns that stored response byte for byte, with
X-Idempotent-Replay: true, and creates nothing. - The same key with a different body is a client bug, not a retry:
409. Use a fresh key for a new request. - Two retries racing each other are arbitrated by the database, never by timing: one runs, the other replays or gets
409. The work happens exactly once. - Keys are honoured for 24 hours. Errors are stored too, so a retry is deterministic.
2. external_ref — your SKU is the identity
Set external_ref to your own product reference and it becomes unique within your account. A nightly resync that re-sends the whole catalogue then cannot create twins:
- A product whose
external_refalready exists comes back as a conflict (409for a single product; inside a bulk call, that item is reported"status": "conflict"with theproduct_idof the row that already holds it). - We never overwrite silently. To change an existing product, call
PUT /api/products/{id}deliberately. - It is optional. Products without a reference never conflict with one another.
- The same rule protects the CSV/XLSX import (add an
external_refcolumn — it is in the downloadable template) and the export carries the column back, so an export → edit → re-import round trip is safe.
Use both. Idempotency-Key protects a single call against a network accident; external_ref protects your catalogue against being loaded twice, ever, by any route.
PUT /api/products/{id}
Update a product. This is a partial update: only the fields present in the body are changed; everything else is left untouched. The product's passport is regenerated in place — its slug and public URL do not change, its version number is incremented, its previous payload is archived to the version history, and its QR cache is purged. Auth: API access + writer. The {id} is the product_id; it may also be passed as ?id=.
Accepts every POST field, plus the extended passport fields manufacturer_id, carbon_scope, recycled_materials, energy_consumption, energy_unit, spare_parts_available, repair_instructions_url, contains_svhc, hazardous_substances, due_diligence. The three boolean fields (spare_parts_available, contains_svhc, due_diligence) accept true/false, 1/0 or "1"/"0". Validation runs on the merged row, so the result must still have a valid name, sector and manufacturer_name.
Example
curl -s -X PUT "https://dpp-tool.com/api/products/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "recycled_content": 72, "energy_class": "A++" }'
Response 200
{
"product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"passport_slug": "ecocharge-pro-5000-a1b2c3",
"passport_url": "https://dpp-tool.com/en/passport/ecocharge-pro-5000-a1b2c3/",
"qr_code_url": "https://dpp-tool.com/api/qrcode?slug=ecocharge-pro-5000-a1b2c3",
"version": 2
}
Missing id → 400. Id not in your account → 404. Emits the product.updated webhook.
DELETE /api/products/{id}
Delete a product. The product row is removed, its passports are archived, not destroyed (a printed QR that points at a passport URL would otherwise break; archived passports return 404 on their public URL while the record is retained), and any uploaded documents attached to the product are purged along with their files. Auth: API access + writer. The {id} may also be passed as ?id=.
Example
curl -s -X DELETE "https://dpp-tool.com/api/products/a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer YOUR_API_KEY"
Response 200
{
"deleted": true,
"product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"passports_archived": 1,
"documents_deleted": 2
}
Missing id → 400. Id not in your account → 404.
GET /api/passport
Fetch a published passport by slug. Public, no key required.
Query parameters
| Parameter | Type | Notes |
|---|---|---|
slug | string | Required. The passport id (e.g. DPP-XXXXXXXXXX) is also accepted, via slug= or id=. |
format | string | json (default), jsonld (Schema.org Product, application/ld+json), or html (a real application/pdf, generated server-side: embedded fonts so any script — including CJK — renders, a vector QR that stays sharp at any print size, the passport's SHA-256 integrity fingerprint printed on the page, and selectable text). Use format=print for the printable HTML view instead.. |
Example
curl -s "https://dpp-tool.com/api/passport?slug=ecocharge-pro-5000-a1b2c3&format=jsonld"
Missing slug/id → 400. Unknown slug → 404. Unknown format → 400 {"error":"Invalid format. Use: json, jsonld, html"}.
GET /api/export
Three modes on one endpoint, selected by the parameters present:
- CSV import template —
?template=csv, public. Returns the blank column template used by the dashboard bulk importer; ignores every other parameter. - Single passport —
?slug=…, public. Exports one published passport as a file. - Bulk account export — no
slug, authenticated (API access). Streams every product of your account for ERP round-trips.
Query parameters
| Parameter | Type | Notes |
|---|---|---|
template | string | csv only. Standalone template download. |
slug | string | Present ⇒ single-passport mode. format is json (default), csv or pdf. |
format | string | Bulk mode: csv (default), json or xlsx. Single-passport mode: json, csv, pdf. |
since | date | Bulk mode only. YYYY-MM-DD. Delta export: only products created or updated on/after this date. An unparseable value returns 400. |
Bulk columns: id, name, sku, gtin, batch, sector, manufacturer, country_of_origin, materials, certifications, recycled_content, carbon_footprint, created_at, updated_at, passport_slug, passport_url, qr_url (sku is sourced from the product's model).
Examples
# Single passport (public)
curl -s "https://dpp-tool.com/api/export?slug=ecocharge-pro-5000-a1b2c3&format=csv" -o passport.csv
# Blank import template (public)
curl -s "https://dpp-tool.com/api/export?template=csv" -o dpp-tool-template.csv
# Whole account, delta since a date (authenticated)
curl -s "https://dpp-tool.com/api/export?format=json&since=2026-07-01" \
-H "Authorization: Bearer YOUR_API_KEY" -o account-export.json
Single-passport mode: missing/unknown slug → 404, unknown format → 400. Bulk mode: unknown format → 400 {"error":"Invalid format. Use: csv, json, xlsx"}; without a key → 401; on a non-API plan → 403. XLSX depends on the server's ZipArchive; where it is unavailable, bulk format=xlsx returns 501 — use format=csv.
GET /api/qrcode
Return the passport's QR code as a PNG image (image/png). Public. The QR encodes the product's GS1 Digital Link URI when it has a valid GTIN, otherwise the passport page URL.
Query parameters
| Parameter | Type | Default | Notes |
|---|---|---|---|
slug | string | — | Required. |
size | int | 300 | Pixel size of the square image. Clamped to 100–1000. |
Example
curl -s "https://dpp-tool.com/api/qrcode?slug=ecocharge-pro-5000-a1b2c3&size=600" -o qr.png
Missing slug → 400. Unknown slug → 404.
GET POST DELETE /api/documents
Attach files — certificates, test reports, declarations of conformity, safety data sheets — to a product. Auth: API access; writer for POST/DELETE. Files are stored server-side and reached through the url field (see /api/document-file). A per-product document quota comes from the plan.
GET — list a product's documents
| Parameter | Type | Required | Notes |
|---|---|---|---|
product_id | string | yes | Query parameter. A product not in your account → 404. |
curl -s "https://dpp-tool.com/api/documents?product_id=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"documents": [
{
"id": "d0c0ffee-0000-4a00-8b00-000000000001",
"product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"type": "certificate",
"title": "CE Declaration",
"issuer": "TUV",
"reference": "CE-2026-001",
"issued_at": "2026-01-15",
"expires_at": "2029-01-15",
"original_name": "ce-declaration.pdf",
"mime": "application/pdf",
"size_bytes": 148213,
"sha256": "9f2c...e1",
"is_public": true,
"created_at": "2026-07-01 10:32:00",
"url": "https://dpp-tool.com/api/document-file?id=d0c0ffee-0000-4a00-8b00-000000000001"
}
],
"quota_left": 19
}
POST — upload (multipart/form-data)
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | string | yes | Owning product. |
file | file | yes | PDF, JPG, PNG or WebP. Max 4 MB. The real MIME is checked from the bytes, not the filename. |
type | string | yes | One of certificate, test_report, declaration, safety_sheet, other. |
title | string | yes | Human-readable title. |
issuer | string | no | Issuing body. |
reference | string | no | Document reference/number. |
issued_at | date | no | YYYY-MM-DD. |
expires_at | date | no | YYYY-MM-DD. |
is_public | bool | no | When true the file is shown on the public passport page and is fetchable without a key. |
curl -s -X POST "https://dpp-tool.com/api/documents" \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "product_id=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" \
-F "type=certificate" \
-F "title=CE Declaration" \
-F "is_public=true" \
-F "[email protected]"
Returns 201 { "document": { … } } with the same row shape as GET. Over quota → 400 {"error":"Document limit reached for your plan (… per product)"}. Wrong type → 400 {"error":"Unsupported file type. Allowed: PDF, JPG, PNG, WebP"}.
DELETE — remove a document
curl -s -X DELETE "https://dpp-tool.com/api/documents?id=d0c0ffee-0000-4a00-8b00-000000000001" \
-H "Authorization: Bearer YOUR_API_KEY"
Returns { "deleted": true, "id": "…" }. Unknown id → 404.
GET /api/document-file
Stream a stored document file (this is the url returned by /api/documents). A public document (is_public = true) is served to anyone; a private one only to a member of the owning account (session or API key). Anything else — wrong account, missing id, missing file — is a 404, so a private id's existence never leaks.
| Parameter | Type | Required | Notes |
|---|---|---|---|
id | string | yes | Document id. |
curl -s "https://dpp-tool.com/api/document-file?id=d0c0ffee-0000-4a00-8b00-000000000001" -o ce-declaration.pdf
The response carries the file's own content type and a Content-Disposition (inline for PDF/images, otherwise attachment).
GET POST PUT DELETE /api/suppliers
Supply-chain actors scoped to your account, and their links to products. Auth: API access; supplier management additionally requires the Pro plan or higher (a suppliers entitlement, enforced for both key and dashboard callers); writer role for POST/PUT/DELETE.
GET
- No parameter →
{ "suppliers": […], "total": N }(whole account). ?id=<supplier_id>→{ "supplier": { … } }, or404.?product_id=<product_id>→{ "suppliers": […], "carbon_footprint_supply_chain": { … } }— the actors linked to that product, each with itsstageand itslink_carbon_footprint_per_unit, plus the aggregate described below.
POST — create a supplier
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Max 200 chars. Unique per (name, role) within your account. |
role | string | yes | One of manufacturer, assembler, dyeing, spinning, weaving, tannery, packaging, logistics, raw_material, other. |
country | string | no | ISO 3166-1 alpha-2 (e.g. DE). |
gln | string | no | 13-digit GLN with a valid GS1 mod-10 check digit. |
contact_email | string | no | Validated email. |
website | string | no | http/https URL. |
carbon_footprint_per_unit | number | no | kg CO2e for one unit of finished product, as declared by this supplier. Never negative. Omitted or empty → null = not reported, which is not the same as 0. |
carbon_method | string | no | How the figure was obtained: primary_measured, iso_14067, pef, epd, secondary_database, estimate. |
carbon_reference_date | string | no | ISO date (YYYY-MM-DD) the figure refers to. |
Optional: city, address, vat_number, duns, notes. | |||
curl -s -X POST "https://dpp-tool.com/api/suppliers" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "GreenTech GmbH", "role": "manufacturer", "country": "DE", "gln": "4006381333931" }'
Returns 201 { "supplier": { … }, "id": "…" }. A duplicate (name, role) → 400.
PUT — update
PUT /api/suppliers?id=<supplier_id>, partial update, validated on the merged row. Returns { "supplier": { … }, "id": "…" }. Unknown id → 404.
DELETE
DELETE /api/suppliers?id=<supplier_id> removes the supplier and its product links. Returns { "deleted": true, "id": "…" }. Unknown id → 404.
Carbon footprint per unit
A supplier carries a default per-unit figure (carbon_footprint_per_unit, kg CO2e per unit of finished product). A product link can carry its own figure, which overrides that default for that product only — the same mill does not emit the same amount for a hoodie and for a denim jacket.
The product total is the sum of the figures declared along its chain, served by GET /api/products/{id} and by GET /api/suppliers?product_id=:
"carbon_footprint_supply_chain": {
"value": 6.75, // null when nobody reported
"unit": "kg CO2e",
"basis": "per unit",
"suppliers_total": 3,
"suppliers_reporting": 2, // actors that actually declared a figure
"complete": false, // true only when every linked actor reported
"aggregation": "sum of declared supplier values",
"breakdown": [
{ "supplier_id": "...", "supplier_name": "...", "role": "dyeing", "stage": "dyeing",
"value": 2.5, "unit": "kg CO2e", "source": "supplier_default",
"method": "estimate", "reference_date": "2026-01-31" }
]
}
An actor that reported nothing is counted in suppliers_total but never guessed at, so a partial total cannot be read as a complete one. In the passport, the value declared on the product wins; when the product carries none, the supply-chain sum fills it and is labelled "source": "supply_chain_aggregate" under sustainability.carbon_footprint, together with the coverage. This is an arithmetic sum of declared values — not a life-cycle assessment, and not a verified product carbon footprint.
# Set (or clear, with null) the figure carried by ONE product-supplier link
curl -s -X POST "https://dpp-tool.com/api/suppliers?id=SUPPLIER_ID&action=carbon" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage": "dyeing", "carbon_footprint_per_unit": 2.5 }'
Returns { "updated": true, "carbon_footprint_per_unit": 2.5, "unit": "kg CO2e", … }. A pair that is not linked at that stage → 404.
Attach / detach a supplier to a product
Both share the POST verb, disambiguated by ?action= and the supplier ?id=; the product id and optional stage go in the JSON body. attach also accepts carbon_footprint_per_unit to set the link's figure in the same call; repeating an attach with a new figure updates it instead of failing.
# Attach
curl -s -X POST "https://dpp-tool.com/api/suppliers?id=SUPPLIER_ID&action=attach" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage": "cell-assembly" }'
# Detach
curl -s -X POST "https://dpp-tool.com/api/suppliers?id=SUPPLIER_ID&action=detach" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "stage": "cell-assembly" }'
{
"attached": true,
"link_id": "11112222-3333-4444-5555-666677778888",
"duplicate": false,
"product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"supplier_id": "SUPPLIER_ID",
"stage": "cell-assembly"
}
Attach is idempotent on (product, supplier, stage): a repeat returns 200 with "duplicate": true; a fresh link returns 201. A foreign or missing product/supplier → 404. Detach returns { "detached": true|false }.
GET /api/passport-versions
Read-only version history of a passport — the immutable audit trail kept for ESPR (Regulation (EU) 2024/1781). Auth: an authenticated caller (API key or dashboard session) that owns the passport; anything else is a 404. GET only (other methods → 405).
| Parameter | Type | Required | Notes |
|---|---|---|---|
slug | string | yes | Passport slug. Missing → 400. |
version | int | no | When present, returns the full stored JSON snapshot of that version; otherwise a metadata listing. |
# Metadata listing
curl -s "https://dpp-tool.com/api/passport-versions?slug=ecocharge-pro-5000-a1b2c3" \
-H "Authorization: Bearer YOUR_API_KEY"
# One snapshot
curl -s "https://dpp-tool.com/api/passport-versions?slug=ecocharge-pro-5000-a1b2c3&version=1" \
-H "Authorization: Bearer YOUR_API_KEY"
{
"slug": "ecocharge-pro-5000-a1b2c3",
"current_version": 2,
"versions": [
{ "version": 2, "created_at": "2026-07-05 09:00:00", "changed_by": "user-uuid" },
{ "version": 1, "created_at": "2026-07-01 10:30:00", "changed_by": null }
]
}
Unknown slug, or one you do not own → 404. Unknown version → 404.
POST /api/import-run
Worker that advances a CSV/XLSX bulk-import job by one bounded batch (at most 25 rows, ~15 s) and returns the job's progress. The browser calls it repeatedly until done is true. Authenticates via the dashboard session only — there is no API-key path, so this endpoint is not part of the key-based integration surface; it backs the dashboard's Import screen (upload the file there to create the job). Writer role required.
| Parameter | Type | Required | Notes |
|---|---|---|---|
job | string | yes | Import job id (query or form field). Must belong to your account, else 404. |
{
"job": "job-uuid",
"status": "running",
"processed": 25,
"total": 120,
"imported": 24,
"failed": 1,
"skipped": 0,
"done": false
}
When the plan's product quota is hit mid-file, the remaining rows are reported as skipped (never silently dropped) and done becomes true. On completion, an import.completed webhook fires once. Not logged in → 401; missing job → 400.
GET /api/webhook-run
Drains due webhook retries for your account within a bounded batch. Authenticated (session or API key) callers drain only their own account's queue. Retries are also drained opportunistically when events are dispatched, so calling this is optional; it exists because the host has no guaranteed cron.
curl -s "https://dpp-tool.com/api/webhook-run" -H "Authorization: Bearer YOUR_API_KEY"
{ "ok": true, "scope": "account", "claimed": 2, "delivered": 2,
"retried": 0, "failed": 0, "dead": 0, "skipped": 0, "elapsed_ms": 830 }
Certifications
certifications is a defined value list, not free text. Send an array of codes (a comma-separated string still works), and the passport carries both the readable labels and their structured form.
curl -s "https://dpp-tool.com/api/certifications"
{
"groups": { "textiles": "Textiles, leather and apparel", "environment": "Environment and energy", "…": "…" },
"certifications": [
{ "code": "gots", "label": "GOTS (Global Organic Textile Standard)", "group": "textiles" },
{ "code": "oeko_tex_standard_100", "label": "OEKO-TEX STANDARD 100", "group": "textiles" }
],
"count": 32
}
Writing them:
{ "name": "Poplin shirt", "manufacturer_name": "ACME Textiles",
"certifications": ["gots", "oeko_tex_standard_100", "amfori_bsci"] }
Common spellings are mapped for you: "OEKO-TEX 100", "oeko tex 100" and "Standard 100 by OEKO-TEX" all resolve to oeko_tex_standard_100.
A value outside the list is never rejected. It is stored as you sent it and returned with "standardised": false, so a national standard or a customer-specific scheme is preserved while remaining visibly unmapped. The field holds 500 characters; entries beyond that are dropped on an entry boundary rather than cut mid-word.
On the passport payload (GET /api/passport), supply_chain.certifications keeps its existing shape — a flat list of labels — and supply_chain.certifications_structured carries the codes.
GS1 Digital Link
Every passport whose product carries a valid GTIN gets a GS1 Digital Link URI of the canonical form:
https://dpp-tool.com/01/{gtin14}[/10/{lot}][/21/{serial}]
where 01 is the GTIN (left-padded to 14 digits), 10 is the batch/lot and 21 is the serial number. This is exactly the URI encoded in the QR code. The resolver is hosted on dpp-tool.com itself — not id.gs1.org — and a scan resolves in a single hop:
GETon the URI →307redirect to the human-readable passport page.- With
Accept: application/jsonor?linkType=all→ a JSON GS1 linkset instead of the redirect. - Invalid or missing GTIN →
400. Valid GTIN with no matching passport →404.
Examples
curl -sI "https://dpp-tool.com/01/04006381333931"
curl -s -H "Accept: application/json" "https://dpp-tool.com/01/04006381333931"
Linkset response
{
"linkset": [
{
"anchor": "https://dpp-tool.com/en/passport/ecocharge-pro-5000-a1b2c3/",
"https://gs1.org/voc/sustainabilityInfo": [
{ "href": "https://dpp-tool.com/en/passport/ecocharge-pro-5000-a1b2c3/",
"title": "Digital Product Passport", "type": "text/html" }
],
"https://gs1.org/voc/productInfo": [
{ "href": "https://dpp-tool.com/api/passport?slug=ecocharge-pro-5000-a1b2c3&format=json",
"title": "Passport data (JSON)", "type": "application/json" }
]
}
]
}
Webhooks
Webhooks are a Business-plan feature (also on Enterprise). Register an endpoint from Dashboard → Settings; a signing secret is generated for it. The endpoint must be a public https:// URL — URLs with credentials, or that resolve to a private or reserved IP address, are refused, both at registration and again at every delivery attempt (so a DNS re-point to a private address never causes an internal request).
Events
product.created— emitted byPOST /api/products.product.updated— emitted byPUT /api/products/{id}.import.completed— emitted once when a CSV/XLSX bulk import finishes.
Request headers
Each delivery is a POST with Content-Type: application/json and User-Agent: DPP-Tool-Webhook/1.0, carrying:
| Header | Value |
|---|---|
X-DPP-Event | The event name, e.g. product.created. |
X-DPP-Delivery-Id | Stable id of this delivery — identical across every retry of the same event. Deduplicate on it. |
X-DPP-Attempt | Attempt number, starting at 1, incremented on each retry. |
X-DPP-Timestamp | Unix timestamp (seconds) of this attempt. Refreshed each attempt. |
X-DPP-Signature | sha256=<hmac> — HMAC-SHA256 of the string "{timestamp}.{body}", keyed with your endpoint's signing secret. Refreshed each attempt. |
At-least-once delivery & idempotency
Delivery is at-least-once, not exactly-once: a delivery may arrive more than once. Deduplicate on X-DPP-Delivery-Id, which is constant across all attempts of one event, while X-DPP-Attempt increments. The request body is byte-identical across attempts; only X-DPP-Timestamp and X-DPP-Signature are recomputed, so a receiver that enforces a timestamp skew window still accepts a legitimate retry.
Retries & dead letters
The originating request makes one immediate attempt. Any outcome that is not a permanent rejection is retried on a fixed backoff schedule. A response is treated as a permanent failure (no retry) only on a 4xx other than 408 and 429; 408, 429, any 5xx, and transport errors (timeout, DNS, TLS) are retried. Each attempt has a 3-second timeout.
| Attempt | When |
|---|---|
| 1 | immediately |
| 2 | +1 minute after attempt 1 fails |
| 3 | +5 minutes |
| 4 | +30 minutes |
| 5 | +2 hours |
| 6 | +6 hours |
After the sixth attempt fails, the delivery becomes a dead letter and is never retried automatically. Dead-lettered and permanently-failed deliveries can be replayed from Dashboard → Settings.
Payload body
Every event body has the shape { "event", "created_at", "data" }, where created_at is the ISO-8601 UTC time the event was enqueued.
{
"event": "product.created",
"created_at": "2026-07-10T09:30:00Z",
"data": {
"product_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"passport_slug": "ecocharge-pro-5000-a1b2c3",
"passport_url": "https://dpp-tool.com/en/passport/ecocharge-pro-5000-a1b2c3/",
"qr_code_url": "https://dpp-tool.com/api/qrcode?slug=ecocharge-pro-5000-a1b2c3",
"name": "EcoCharge Pro 5000",
"sector": "batteries",
"gtin": "4006381333931",
"gs1_digital_link": "https://dpp-tool.com/01/04006381333931/10/LOT42",
"source": "api"
}
}
product.updated carries the same shape plus a version field; import.completed carries { import_id, filename, total_rows, imported_rows, error_rows } in data.
Verify the signature — PHP
<?php
$secret = 'your_signing_secret'; // from Dashboard → Settings
$payload = file_get_contents('php://input'); // the RAW request body
$timestamp = $_SERVER['HTTP_X_DPP_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_DPP_SIGNATURE'] ?? '';
$deliveryId = $_SERVER['HTTP_X_DPP_DELIVERY_ID'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}
// Optional replay protection:
// if (abs(time() - (int)$timestamp) > 300) { http_response_code(401); exit; }
// Deduplicate: a delivery may arrive more than once. Skip if $deliveryId is
// one you have already processed (store it keyed by X-DPP-Delivery-Id).
http_response_code(200);
Verify the signature — Python
import hmac, hashlib
def verify(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
signed = f"{timestamp}.{raw_body.decode('utf-8')}".encode("utf-8")
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask example:
# ts = request.headers["X-DPP-Timestamp"]
# sig = request.headers["X-DPP-Signature"]
# dvid = request.headers["X-DPP-Delivery-Id"] # dedupe key
# ok = verify(SECRET, ts, sig, request.get_data()) # get_data() = raw bytes
Always compute the HMAC over the raw, unparsed request body. Re-serializing the parsed JSON can change byte-for-byte content and break the signature.
Errors
Errors are returned as JSON with the HTTP status set accordingly. Shape:
{ "error": "Human-readable message" }
Create/update validation errors additionally carry a details array:
{
"error": "Validation failed",
"details": [
"Product name is required",
"Valid ESPR sector is required",
"Manufacturer name is required"
]
}
| Code | When | Body |
|---|---|---|
| 400 | Missing/invalid parameter, invalid format, invalid GTIN, bad since date, unsupported upload type, or a failed create/update validation. | {"error":"Validation failed","details":[...]} or a single error message. |
| 401 | Missing or invalid API key (or, for import-run, no dashboard session). | {"error":"Authentication required"} |
| 403 | Plan without API access. | {"error":"API access requires Pro plan or higher"} |
| 403 | Read-only role on the account. | {"error":"Your role on this account is read-only"} |
| 403 | Product quota reached. | {"error":"Product limit reached for your plan"} |
| 403 | Supplier endpoints without the suppliers entitlement. | {"error":"Supplier management requires the Pro plan or higher"} |
| 404 | Unknown or unowned resource (product, supplier, document, passport, job, GTIN, endpoint). | {"error":"... not found"} |
| 405 | HTTP method not supported on the endpoint. | {"error":"Method not allowed"} |
| 429 | Hourly rate limit exceeded. Retry-After: 60 header sent. | {"error":"Rate limit exceeded. Please try again later."} |
| 500 / 501 | Server-side failure; 501 when bulk xlsx export is unavailable on the host. | {"error":"..."} |
Limits
- Rate limits (see the Rate limits section above): Pro 600, Business 2000, Enterprise 5000 requests/hour, per user, per rolling hour.
per_pageonGET /api/productsis capped at 100.- A bulk
POST /api/productscarries at most 200 products and counts as one request against your rate limit. product.createdfires once per product — including inside a bulk call. Deliveries are queued and sent in the background, so subscribing to it while loading a large catalogue (tens of thousands of SKUs) will build a backlog that takes a while to drain. For a mass load, subscribe toimport.completedinstead, or simply pollGET /api/products?cursor=…. A slow or failing endpoint of yours never blocks the API response, and never delays another customer's deliveries.- Product quota by plan: Free 3, Starter 50, Pro 500, Business/Enterprise effectively unlimited.
- Document uploads: max 4 MB per file, types PDF/JPG/PNG/WebP; per-product document quota by plan (Free 2, Starter 5, Pro 20, Business/Enterprise 100).
- There is no bulk-create endpoint over the API. Bulk CSV/XLSX import is driven from the dashboard (Dashboard → Import); download the column template from
GET /api/export?template=csv. - Webhook deliveries retry up to six times on a fixed backoff before dead-lettering; each attempt has a 3-second timeout.