Conventions
Requests and responses
- Bodies are JSON (
Content-Type: application/json), apart from file uploads, which aremultipart/form-data. - Times are ISO 8601 with a time zone, e.g.
2026-10-27T09:30:00Zor2026-10-27T10:30:00+01:00. DDM answers in UTC. - IDs are UUIDs.
- Error messages come in English or German, following
Accept-Language. Programs should readerror.code, which never changes, rather than the message.
IDs and references
Every document and category has DDM's own id. Things another system creates usually also carry its id, the reference (externalRef):
- A document's reference is unique within its push source. Two systems may both use
4711. - A category's reference applies across the whole category tree.
Endpoints under …/ref/{ref} address an object by its reference. That makes pushing idempotent: send the same request twice and you still have one document.
Both ways, everywhere
Every operation exists in both forms – by id and by reference:
| Documents | Categories |
|---|---|
GET, PUT, PATCH, DELETE /documents/{id} | GET, PATCH, DELETE /categories/{id} |
GET, PUT, PATCH, DELETE /documents/ref/{ref} | GET, PUT, PATCH, DELETE /categories/ref/{ref} |
…/content, …/text, …/thumbnail under both | POST /categories creates without a reference |
Where a request points at other objects, every id has its reference counterpart – and the two may be mixed:
| By id | By reference | Where |
|---|---|---|
categoryIds | categoryRefs | Push, PUT/PATCH of a document – 1–20 together |
parentId | parentRef | Creating and changing a category – one of the two |
categoryId | categoryRef | Filter of the document list |
{ "categoryIds": ["7a7afae0-f77b-478b-975d-d29eace15d51"], "categoryRefs": ["PIM-CAT-12"] }
A reference that names nothing is refused with 400 and the exact field (e.g. categoryRefs.1). Answers carry both: id and externalRef, documents also categoryRefs beside categoryIds, categories parentRef beside parentId.
A document by reference is looked for in the key's push source first, then among all documents the key can see – so a read-only key finds documents by reference too. If several documents carry the same reference (from different sources), DDM answers 409; address the document by id then.
References may contain any characters (up to 512), but have to be URL-encoded in the path: PIM/4711 becomes PIM%2F4711. Use encodeURIComponent (JavaScript), urllib.parse.quote(ref, safe="") (Python) or [uri]::EscapeDataString (PowerShell).
Titles per market
titles is an object keyed by market code, e.g. { "DE": "Preisliste 2026", "AT": "Preisliste 2026" }. GET /markets lists the codes. Market codes are the markets' country codes, e.g. DE, AT or CH, and are compared regardless of case. When you change titles, only the markets you name change; an empty string removes that market's title. An unknown market code is refused with 400.
Only some fields
With fields you get only the attributes you need — on every reading request for documents and categories, single and in lists. Separate names with commas; a dot reaches into an object:
GET /api/ext/v1/documents?fields=name,titles.DE,currentVersion.versionNo,currentVersion.checksum
{
"data": [
{
"id": "8d3f…",
"name": "Preisliste 2026.pdf",
"titles": { "DE": "Preisliste 2026" },
"currentVersion": { "versionNo": 3, "checksum": "9f86d0…" }
}
],
"nextCursor": null
}
currentVersionreturns the whole object,currentVersion.versionNoonly that field of it.- For
titlesthe market code is the second level:titles.DE,titles.AT. A document without a title for that market simply has no entry. - Lists such as
tagsandcategoryIdsalways come whole. idis always included. Fields of the list itself, such asnextCursor, are not affected.- An unknown field is refused with
400and the list of possible fields.
Paging
Lists come in pages:
{ "data": [ … ], "nextCursor": "WyIyMDI2LTEwLTI3IDA5OjMwOjAwLjEyMzQ1NiswMCIsIjhkM…" }
To get the next page, send nextCursor back as cursor. On the last page nextCursor is null. limit sets the page size, from 1 to 500 (default 100). A cursor is opaque: do not build one yourself or rely on what is inside it.
Change feed
GET /documents is sorted by last change, oldest first. With updatedSince it returns only what changed at or after that time, which makes it a change feed:
- Remember the
updatedAtof the last document you processed. - Next time, ask with
updatedSinceset to that value, and page through withcursor.
You may see the last document of the previous run again, because "at or after" includes it. Process documents so that seeing one twice does no harm.
What counts as a change:
- New bytes (a new version), from anywhere.
- A change to the name, status, sharing or highlight, through the API or in the CMS.
- A change to the tags, titles or filing made through the API.
Changes to tags, titles or filing made in the CMS do not move updatedAt. If you mirror those, also compare the manifest from time to time: it carries each document's categoryIds. Re-reading a document with GET /documents/{id} always gives its current tags and titles.
Deletions do not appear in the feed, because a deleted document no longer exists. To find them, use the manifest.
Files
- Uploading:
multipart/form-datawith the file in thefilepart, up to the plan's maximum file size. Or JSON with afileUrlthat DDM downloads, with the same limit. - Downloading:
GET /documents/{id}/contentreturns the file withContent-Type,Content-Lengthand anETag.- Send
If-None-Matchwith the lastETag, and an unchanged file answers304 Not Modifiedwithout a body. Range: bytes=…resumes an interrupted download (206 Partial Content).
- Send
- Every document also tells you its current version's SHA-256 checksum (
currentVersion.checksum). Compare it with your copy to know whether you need to download at all.
Limits
| What | Limit |
|---|---|
| Requests per key | 600 per minute (429, with Retry-After) |
| File upload | The plan's maximum file size and the organisation's storage (413) |
fileUrl download | The plan's maximum file size and the organisation's storage (413). HTTP(S) only, no redirects, at most 15 minutes |
| Page size | 1–500 |
Manifest (/sync) | 50,000 documents |
| Reference | 512 characters |
| Tags per object | 50 |
Errors
Every error has the same shape:
{
"error": {
"code": "invalid_request",
"message": "Please enter a name.",
"issues": [{ "path": "name", "message": "Please enter a name." }]
}
}
issues is present when individual fields are invalid.
| Status | code | Meaning | What to do |
|---|---|---|---|
| 400 | invalid_request | The request does not validate – see issues | Fix the request |
| 401 | unauthorized | No key, or one that is invalid, revoked or expired | Check the key; do not retry |
| 403 | forbidden | The key may not do this: missing scope, missing permissions, blocked person, push source switched off, plan | Change the key or permissions; do not retry |
| 404 | not_found | Not there, or not visible to this key | Do not retry |
| 409 | conflict | The current state does not allow it: an approval is running, a retention hold, a category that still holds documents | Resolve in the CMS, or send differently |
| 410 | gone | No longer available | – |
| 413 | too_large | The file is too large, or storage is full | – |
| 415 | unsupported_media_type | Neither JSON nor multipart | Fix Content-Type |
| 422 | invalid_request | The file could not be fetched from fileUrl or could not be stored | Check the URL; retry later |
| 429 | rate_limited | Too many requests | Wait Retry-After seconds |
| 5xx | internal | Something went wrong in DDM | Retry later with back-off |
429 and 5xx, with growing pauses (e.g. 1, 2, 4, 8 seconds). Pushing by reference is idempotent, so retrying a PUT is always safe.