Aller au contenu principal

L'accès à l'API est inclus dans tous les plans payants, à partir de Micro.

Voir les plans

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.
  • Send a real User-Agent header. Our host's firewall rejects a handful of default library agents before the request ever reaches the API — notably Python's standard urllib (Python-urllib/3.x), which comes back as a bare 403 on 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, 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

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 — GET requests succeed. POST, PUT and DELETE are refused with 403 and 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.

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 (any paid plan, from Micro). 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) 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

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

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 (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", "…": "…"}
  ]
}
StatusMeaning
201Every product in the array was created.
207Partial success. Read results[]: each entry is created, error (with details) or skipped. The products that succeeded are created — do not resend them.
400Nothing was created (all items invalid, or the array was empty).
403Your plan's product limit was already reached, so nothing was created.
413More 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_ref already exists comes back as a conflict (409 for a single product; inside a bulk call, that item is reported "status": "conflict" with the product_id of 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_ref column — 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

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 (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=battery returns 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

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 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

ParameterTypeDefaultNotes
slugstring—Required.
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 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

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",
      "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

FieldTypeRequiredNotes
product_idstringyesOwning product.
filefileone of file / urlPDF, JPG, PNG or WebP. Max 20 MB. The real MIME is checked from the bytes, not the filename.
urlstringone of file / urlLink 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.
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]"

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.

ParameterTypeRequiredNotes
idstringyesDocument 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": { … } }, or 404.
  • ?product_id=<product_id> → { "suppliers": […], "carbon_footprint_supply_chain": { … } } — the actors linked to that product, each with its stage and its link_carbon_footprint_per_unit, plus the aggregate described below.

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.
carbon_footprint_per_unitnumbernokg 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_methodstringnoHow the figure was obtained: primary_measured, iso_14067, pef, epd, secondary_database, estimate.
carbon_reference_datestringnoISO 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).

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 }

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 "" or null is cleared. Every change seals a new passport version.
  • Strict validation: an unknown key, a level-4 key, a malformed value (number, YYYY-MM month, https:// link, value list) or a battery object on another sector is a 400, with each field named in details as battery.<key>. Nothing is written.
  • Reading: GET /api/products/{id} returns battery.values (every level, it is your data), battery.missing (required model-level fields still empty) and battery.schema_version. The public passport (GET /api/passport) only carries level 1.
  • Bulk update (PUT /api/products with ids) 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:

  • 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. 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) → 307 to 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 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 a paid plan (Micro or higher)"}
403A write authenticated by the dashboard session without its CSRF token (calls with an API key are not affected).{"error":"Missing or invalid CSRF token. ..."}
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.
  • A bulk POST /api/products carries at most 200 products and counts as one request against your rate limit.
  • product.created fires 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 to import.completed instead, or simply poll GET /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/products or a document attach with product_ids carries 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_ref is already in your account instead of creating them. 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.