Ir para o conteúdo principal

O acesso à API requer um plano Pro ou superior.

Ver Planos

Base 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 use multipart/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 a POST is replayed by clients as a GET that drops the request body — a valid create then came back as a 400. The redirect has been removed, but callers should still not add the slash.
  • The public read endpoints (passport, export single-passport mode, qrcode, and the GS1 resolver) need no key and operate on a published passport slug. Everything else is authenticated.
  • Timestamps in JSON bodies are either MySQL DATETIME strings (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.

PlanRequests / hour
Free60
Starter120
Pro600
Business2000
Enterprise5000

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

  1. Create an API key in Dashboard → Settings (Pro plan or higher). Copy it — it is shown only once.
  2. Create a product with POST /api/products. The response carries the generated passport_slug, its public passport_url and its qr_code_url.
  3. Fetch the passport and QR: the passport JSON is public at GET /api/passport?slug=…, the printable PNG at GET /api/qrcode?slug=…, and a scan of the product's GS1 Digital Link resolves to the passport page.
  4. Subscribe a webhook (Business/Enterprise) in Dashboard → Settings to receive product.created, product.updated and import.completed events 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

ParameterTypeDefaultNotes
pageint11-based page number.
per_pageint20Items per page. Clamped to the range 1–100.
cursorstringOpaque 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

FieldTypeRequiredNotes
namestringyesProduct name.
manufacturer_namestringyesLegal manufacturer name.
sectorstringnoDefaults 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

ParameterTypeNotes
slugstringRequired. The passport id (e.g. DPP-XXXXXXXXXX) is also accepted, via slug= or id=.
formatstringjson (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/id400. 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

ParameterTypeNotes
templatestringcsv only. Standalone template download.
slugstringPresent ⇒ single-passport mode. format is json (default), csv or pdf.
formatstringBulk mode: csv (default), json or xlsx. Single-passport mode: json, csv, pdf.
sincedateBulk 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 slug404, unknown format400. 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

ParameterTypeDefaultNotes
slugstringRequired.
sizeint300Pixel 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 slug400. 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

ParameterTypeRequiredNotes
product_idstringyesQuery 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)

FieldTypeRequiredNotes
product_idstringyesOwning product.
filefileyesPDF, JPG, PNG or WebP. Max 4 MB. The real MIME is checked from the bytes, not the filename.
typestringyesOne of certificate, test_report, declaration, safety_sheet, other.
titlestringyesHuman-readable title.
issuerstringnoIssuing body.
referencestringnoDocument reference/number.
issued_atdatenoYYYY-MM-DD.
expires_atdatenoYYYY-MM-DD.
is_publicboolnoWhen 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.

ParameterTypeRequiredNotes
idstringyesDocument 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": { … } }, or 404.
  • ?product_id=<product_id>{ "suppliers": […] } linked to that product, each with its stage.

POST — create a supplier

FieldTypeRequiredNotes
namestringyesMax 200 chars. Unique per (name, role) within your account.
rolestringyesOne of manufacturer, assembler, dyeing, spinning, weaving, tannery, packaging, logistics, raw_material, other.
countrystringnoISO 3166-1 alpha-2 (e.g. DE).
glnstringno13-digit GLN with a valid GS1 mod-10 check digit.
contact_emailstringnoValidated email.
websitestringnohttp/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).

ParameterTypeRequiredNotes
slugstringyesPassport slug. Missing → 400.
versionintnoWhen 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.

ParameterTypeRequiredNotes
jobstringyesImport 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:

  • GET on the URI → 307 redirect to the human-readable passport page.
  • With Accept: application/json or ?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 by POST /api/products.
  • product.updated — emitted by PUT /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:

HeaderValue
X-DPP-EventThe event name, e.g. product.created.
X-DPP-Delivery-IdStable id of this delivery — identical across every retry of the same event. Deduplicate on it.
X-DPP-AttemptAttempt number, starting at 1, incremented on each retry.
X-DPP-TimestampUnix timestamp (seconds) of this attempt. Refreshed each attempt.
X-DPP-Signaturesha256=<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.

AttemptWhen
1immediately
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"
  ]
}
CodeWhenBody
400Missing/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.
401Missing or invalid API key (or, for import-run, no dashboard session).{"error":"Authentication required"}
403Plan without API access.{"error":"API access requires Pro plan or higher"}
403Read-only role on the account.{"error":"Your role on this account is read-only"}
403Product quota reached.{"error":"Product limit reached for your plan"}
403Supplier endpoints without the suppliers entitlement.{"error":"Supplier management requires the Pro plan or higher"}
404Unknown or unowned resource (product, supplier, document, passport, job, GTIN, endpoint).{"error":"... not found"}
405HTTP method not supported on the endpoint.{"error":"Method not allowed"}
429Hourly rate limit exceeded. Retry-After: 60 header sent.{"error":"Rate limit exceeded. Please try again later."}
500 / 501Server-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_page on GET /api/products is 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.