Integration API

Conventions

Formats, references, paging, change feeds, files, limits and errors – what every endpoint has in common.

Requests and responses

  • Bodies are JSON (Content-Type: application/json), apart from file uploads, which are multipart/form-data.
  • Times are ISO 8601 with a time zone, e.g. 2026-10-27T09:30:00Z or 2026-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 read error.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:

DocumentsCategories
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 bothPOST /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 idBy referenceWhere
categoryIdscategoryRefsPush, PUT/PATCH of a document – 1–20 together
parentIdparentRefCreating and changing a category – one of the two
categoryIdcategoryRefFilter 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
}
  • currentVersion returns the whole object, currentVersion.versionNo only that field of it.
  • For titles the 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 tags and categoryIds always come whole.
  • id is always included. Fields of the list itself, such as nextCursor, are not affected.
  • An unknown field is refused with 400 and 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:

  1. Remember the updatedAt of the last document you processed.
  2. Next time, ask with updatedSince set to that value, and page through with cursor.

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-data with the file in the file part, up to the plan's maximum file size. Or JSON with a fileUrl that DDM downloads, with the same limit.
  • Downloading: GET /documents/{id}/content returns the file with Content-Type, Content-Length and an ETag.
    • Send If-None-Match with the last ETag, and an unchanged file answers 304 Not Modified without a body.
    • Range: bytes=… resumes an interrupted download (206 Partial Content).
  • 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

WhatLimit
Requests per key600 per minute (429, with Retry-After)
File uploadThe plan's maximum file size and the organisation's storage (413)
fileUrl downloadThe plan's maximum file size and the organisation's storage (413). HTTP(S) only, no redirects, at most 15 minutes
Page size1–500
Manifest (/sync)50,000 documents
Reference512 characters
Tags per object50

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.

StatuscodeMeaningWhat to do
400invalid_requestThe request does not validate – see issuesFix the request
401unauthorizedNo key, or one that is invalid, revoked or expiredCheck the key; do not retry
403forbiddenThe key may not do this: missing scope, missing permissions, blocked person, push source switched off, planChange the key or permissions; do not retry
404not_foundNot there, or not visible to this keyDo not retry
409conflictThe current state does not allow it: an approval is running, a retention hold, a category that still holds documentsResolve in the CMS, or send differently
410goneNo longer available–
413too_largeThe file is too large, or storage is full–
415unsupported_media_typeNeither JSON nor multipartFix Content-Type
422invalid_requestThe file could not be fetched from fileUrl or could not be storedCheck the URL; retry later
429rate_limitedToo many requestsWait Retry-After seconds
5xxinternalSomething went wrong in DDMRetry later with back-off
Retry only 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.