API-dokumentation
Integrera DPP-Tool i dina befintliga system.
API-åtkomst ingår i alla betalda planer, från Micro.
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"}.
Integrations should always use a key. A write (POST, PUT, DELETE) authenticated by a signed-in dashboard session instead of a key must also send that session's CSRF token in an X-CSRF-Token header, otherwise it returns 403. Calls made with a key are not affected.
API access is a paid entitlement. A key only works on a paid plan: Micro, Starter, Pro, Business or Enterprise (Micro and Starter at 120 requests per hour). On the Free plan (or once a paid plan ends) authenticated calls to the products, documents and suppliers endpoints return 403 {"error":"API access requires a paid plan (Micro or higher)"}. The whole-catalogue export, import, bulk update and supplier management stay Pro features. 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 (any paid plan, from Micro). 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.
PUT /api/products bulk
Apply the same change to several products at once, for example a new material composition on every batch of one model. Send no id in the path and a body with ids (up to 200 product ids) and set (the fields to write). Only the listed products are touched; each one whose values actually change gets a new passport version (same slug, same QR code). A product that already had every value sent is reported in unchanged and gets no new version. batch_number and external_ref cannot be set in bulk, since they identify one batch. Auth: API access + writer, Pro plan or higher.
curl -s -X PUT "https://dpp-tool.com/api/products" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "ids": ["a1b2c3d4-…", "b2c3d4e5-…"], "set": { "material_composition": "Recycled PES 60%, PU coating 40%" } }'
Response 200
{ "updated": 2, "unchanged": 0, "failed": {}, "not_found": 0 }
failed maps a product id to its validation error; the other products are still updated. To attach a certificate to several products, see POST /api/documents with document_id.
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.?template=batteryreturns the battery template (bat_<key>columns, see Battery passport fields). - 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.
POST GET /api/export-archive
The full archive of your account, on every plan: every product, every passport it ever had, every sealed version with its hashes (data_sealed is the exact string the hashes cover) and a fresh check of the chain, every document including removed ones, and the stored files. It also holds site/, a static copy of your published passports with the GS1 Digital Link paths (/01/{gtin14}/10/{lot}/): uploaded to the domain printed in your QR codes, it serves them without us. Built in the background as ZIP volumes (a few hundred products each) plus a manifest.json with each volume's SHA-256. Available for 7 days; starting a new archive replaces the previous one. Also in Dashboard → Settings → Export everything.
# 1. start
curl -s -X POST "https://dpp-tool.com/api/export-archive?action=start" -H "Authorization: Bearer YOUR_API_KEY"
# 2. build the volumes: call until "done" is true
curl -s "https://dpp-tool.com/api/export-archive?action=run&job=JOB_ID" -H "Authorization: Bearer YOUR_API_KEY"
# 3. download each file listed in "files"
curl -s "https://dpp-tool.com/api/export-archive?action=download&job=JOB_ID&file=volume-0001.zip" -H "Authorization: Bearer YOUR_API_KEY" -o volume-0001.zip
{ "job": "…", "status": "completed", "products": 1200, "processed": 1200, "volumes": 6, "done": true,
"expires_at": "2026-10-27 10:00:00",
"files": [ { "name": "volume-0001.zip", "size": 52428800, "url": "…" }, …, { "name": "manifest.json", … } ] }
action=status returns the same shape without doing any work.
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 documents — certificates, test reports, declarations of conformity, safety data sheets, manuals — to a product, either as an uploaded file or as a link to a PDF that stays on your own server. Auth: API access; writer for POST/DELETE. Both kinds are reached through the url field (see /api/document-file). Uploaded files count against the plan's per-product document quota; links do not (up to 50 links per product).
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",
"storage": "file",
"source_url": null,
"link_checked_at": null
}
],
"quota_left": 19,
"links_left": 50
}
Add &include=history to also list the documents removed from the product: they are kept (with their deleted_at date) because a passport version that listed them must stay provable. Removed documents are no longer public; only your account can still open them.
For a link document, storage is "link", source_url is the link you sent, sha256 is null (we never hold the bytes), and size_bytes is the size your server reported at the check (0 if it did not say).
POST — upload a file (multipart/form-data) or add a link
| Field | Type | Required | Notes |
|---|---|---|---|
product_id | string | yes | Owning product. |
file | file | one of file / url | PDF, JPG, PNG or WebP. Max 20 MB. The real MIME is checked from the bytes, not the filename. |
url | string | one of file / url | Link to a PDF or image on your own server. Must be https://, on a public host name (no IP address, standard port), and lead straight to the file (no viewer or login page). Checked once, when added: we read the first bytes only and keep no copy. Up to 3 redirects are followed, each checked the same way. |
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]"
A link can be sent as form fields or as JSON:
curl -s -X POST "https://dpp-tool.com/api/documents" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"product_id":"a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d","type":"declaration","title":"EU Declaration of Conformity","is_public":true,"url":"https://www.example.com/docs/doc-ebp-5000.pdf"}'
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"}. A link that fails its check → 400 with the reason (e.g. "The file is not publicly accessible (HTTP 403)", "The link does not lead directly to a PDF or an image file …").
Adding or removing a public document creates a new passport version: each version seals the list of public documents in force (title, type, dates, the file's SHA-256 or the link), so the history shows which certificate applied to which batch, and when.
POST — attach an existing document to other products
Send document_id and product_ids (up to 200) instead of a file or url. The file is not copied: each product gets its own document entry pointing at the same stored file (or the same link), so one certificate can cover hundreds of batches without using storage again. Products that already carry it, or that reached their file quota, are listed in skipped. Pro plan or higher.
curl -s -X POST "https://dpp-tool.com/api/documents" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"document_id":"d0c0ffee-0000-4a00-8b00-000000000001","product_ids":["b2c3d4e5-…","c3d4e5f6-…"]}'
{ "attached": ["b2c3d4e5-…", "c3d4e5f6-…"], "skipped": {} }
DELETE — remove a document from its product
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, "archived": true, "id": "…" }. The document leaves the passport (new version) but is not erased: it stays in the history and in the full archive. Unknown id → 404.
GET /api/document-file
Stream a stored document file, or redirect (302) to the source_url of a link document (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
For a link document, use curl -L to follow the redirect. For a stored file, 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 — an immutable integrity trail designed to support future DPP obligations under 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.
Battery passport fields (Annex XIII)
Products with "sector": "batteries" carry the information of Regulation (EU) 2023/1542, Annex XIII, in a battery object. The field list, with type, unit, access level and legal reference, is public:
curl -s "https://dpp-tool.com/api/sector-schema?sector=batteries"
Each field has an access level: 1 = public (printed in the passport, its JSON, JSON-LD and PDF); 2 = persons with a legitimate interest and the Commission; 3 = notified bodies, market surveillance authorities and the Commission. Levels 2 and 3 are stored with the product but never published: the sealed passport only carries a salted SHA-256 commitment of them, and they are shown through the signed access links you issue from the product page. Level 4 (individual battery: status, state of health, usage) belongs to serial-number passports and is refused on a model passport.
Writing them, with POST /api/products (single or bulk) or PUT /api/products/{id}:
{ "name": "VeloCell 36V 17.5Ah", "sector": "batteries", "manufacturer_name": "VeloCell GmbH",
"battery": { "battery_category": "lmt", "weight_kg": 3.2, "chemistry": "Li-ion NMC 811",
"manufacture_date": "2026-09", "cathode_materials": "LiNi0.8Mn0.1Co0.1O2, PVDF binder" } }
- Partial update: on
PUT, keys you do not send keep their value; a key sent as""ornullis cleared. Every change seals a new passport version. - Strict validation: an unknown key, a level-4 key, a malformed value (number,
YYYY-MMmonth,https://link, value list) or abatteryobject on another sector is a400, with each field named indetailsasbattery.<key>. Nothing is written. - Reading:
GET /api/products/{id}returnsbattery.values(every level, it is your data),battery.missing(required model-level fields still empty) andbattery.schema_version. The public passport (GET /api/passport) only carries level 1. - Bulk update (
PUT /api/productswithids) does not take battery fields: send them per product. - Import: one column per field named
bat_<key>. Template with an example row:GET /api/export?template=battery.
GS1 Digital Link
Every passport whose product carries a valid GTIN gets a GS1 Digital Link URI. Its host depends on your account:
https://passport.yourcompany.com/01/{gtin14}[/10/{lot}][/21/{serial}] (your own domain or subdomain is active)
https://dpp-tool.com/r/{account key}/01/{gtin14}[/10/{lot}][/21/{serial}] (otherwise)
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 link is scoped to your account: on your domain, or under your account key, only your passports can answer, even if another company enters the same GTIN. The resolver is hosted by us — 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. A browser gets a short page in its own language explaining it (never raw JSON); a JSON client gets{"error":"..."}with the same status. - With a batch (
/10/{lot}), the passport of that batch answers. Batch numbers are case-sensitive, as in GS1 (AI 10): an exact match wins; a match that differs only by case still opens when it points at one single batch. An unknown batch →404, and the page lists the batches of that GTIN that do have a passport. - With the GTIN alone: one batch (or a passport entered without a batch) →
307to it. Several batches and none without a batch →300 Multiple Choices, with a page to choose the batch (or, in JSON, a linkset listing one passport per batch), as the GS1-Conformant Resolver Standard recommends when no single answer can be determined. - QR codes printed before 20 October 2026 carry
https://dpp-tool.com/01/…without an account key. They keep working: the account that first published a passport for that GTIN answers them. - The link of an existing passport is updated to the scoped form at its next version; codes already printed keep resolving.
Examples
curl -sI "https://dpp-tool.com/r/a1b2c3d4/01/04006381333931/10/KORD-001"
curl -s -H "Accept: application/json" "https://dpp-tool.com/r/a1b2c3d4/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 a paid plan (Micro or higher)"} |
| 403 | A write authenticated by the dashboard session without its CSRF token (calls with an API key are not affected). | {"error":"Missing or invalid CSRF token. ..."} |
| 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 5, Starter 100, Pro 2,000, Business/Enterprise unlimited. Deactivated products do not count.
- A bulk
PUT /api/productsor a document attach withproduct_idscarries at most 200 products. - Document uploads: max 20 MB per file, types PDF/JPG/PNG/WebP; per-product document quota by plan (Free 2, Starter 5, Pro 20, Business 100, Enterprise unlimited). Document links are outside this quota, up to 50 per product.
- Bulk CSV/XLSX import is driven from the dashboard (Dashboard → Import). Tick Update existing products to update the products whose
external_refis already in your account instead of creating them. Download the column template fromGET /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.