Documentação API
Integre o DPP-Tool nos seus sistemas existentes.
O acesso à API requer um plano Pro ou superior.
Ver PlanosBase 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. - 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
The key is shown once at creation time — store it securely; it cannot be retrieved later, only revoked 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).
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",
"slug": "ecocharge-pro-5000-x1y2",
"created_at": "2026-07-01 10:30:00",
"updated_at": "2026-07-01 10:30:00"
}
],
"total": 1,
"page": 1,
"per_page": 20,
"next_cursor": null
}
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"
}
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 (comma-separated), 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.
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 (printable PDF). |
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 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": […] }linked to that product, each with itsstage.
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. |
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.
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
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 }
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.- 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.