Schnellstart mit curl und CSharp

Dieser Artikel führt einen vollständigen Ablauf durch: Akte suchen, Dokument hochladen, Dokument herunterladen. Zuerst mit curl, damit jeder Schritt sichtbar bleibt, danach dasselbe in C#.

Er richtet sich an alle, die selbst gegen die Schnittstelle entwickeln. Voraussetzung ist eine registrierte Applikation und ein gültiges Zugriffstoken – beides beschreibt Eine eigene Anwendung anbinden.

BASE="https://<mandant>.stp-cloud.de/documents/dms-cloud-api/api"
TOKEN="eyJhbGciOi..."

Eine Akte oder einen Ordner finden

Drei Suchvarianten stehen zur Verfügung, die sich gegenseitig ausschließen:

Parameter Wirkung
name Exakte Suche über den Aktennamen
fileReference Exakte Suche über das Aktenzeichen
searchTerm Präfixsuche über beides
(keiner davon) Seitenweises Durchblättern aller Akten – mit containerType=folder stattdessen aller eigenen Ordner
curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/dms/documents/container?searchTerm=Mustermann&pageSize=25"
{
  "containers": [
    {
      "containerId": "1f0c9d1e-6f3b-4a2c-9d20-6b0f4f8a1c77",
      "name": "Mustermann ./. Musterbank",
      "fileReference": "2026-0042",
      "type": "Akte"
    }
  ],
  "totalCount": 1,
  "nextContinuationToken": null
}

Enthält nextContinuationToken einen Wert, gibt es weitere Seiten. Er wird beim nächsten Aufruf unverändert als continuationToken mitgeschickt.

Ein Seitenzeiger gehört zu genau der Abfrage, die ihn erzeugt hat. Ändert sich das Suchkriterium zwischen zwei Seiten – etwa weil im Suchfeld weitergetippt wurde –, ist der alte Zeiger ungültig und die Antwort ist 400 VALIDATION_FAILED. In dem Fall wird die Suche ohne continuationToken neu begonnen. Dasselbe gilt für die Dokumentlisten: ein Zeiger aus einer Akte gilt nicht in einer anderen und nicht in einem anderen Register. Ein Kriterium mitzuschicken, das leer ist oder nur aus Leerzeichen besteht, wird ebenfalls mit 400 abgelehnt – wer alles durchblättern möchte, lässt den Parameter weg.

Die Dokumente der Akte auflisten

CONTAINER="1f0c9d1e-6f3b-4a2c-9d20-6b0f4f8a1c77"

curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/dms/documents/container/$CONTAINER/documents?pageSize=50"
{
  "documents": [
    {
      "documentId": "8a3d2f10-77bc-4de1-9a55-2b6f9ac13d04",
      "name": "00_Klageschrift_ArbG_Hagen_CBF219FC.pdf",
      "title": "Klageschrift Arbeitsgericht Hagen",
      "documentType": "pdf",
      "sizeBytes": 184320,
      "modifiedAt": "2026-08-19T14:02:11Z",
      "letterDate": "2026-08-04T00:00:00+02:00"
    }
  ],
  "totalCount": 1,
  "nextContinuationToken": null
}

Die Registerstruktur der Akte lesen

Die Dokumentliste oben ist flach: sie nennt alle Dokumente der Akte, aber nicht, in welchem Register sie liegen. Die Untergliederung der Akte kommt aus einem eigenen Aufruf:

curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/dms/documents/container/$CONTAINER/filing-tray"
{
  "filingTrays": [
    {
      "trayId": 17,
      "name": "Schriftverkehr",
      "displayText": "Schriftverkehr",
      "parentTrayId": 0,
      "documentCount": 12,
      "position": 1
    },
    {
      "trayId": 23,
      "name": "Gericht",
      "displayText": "Schriftverkehr Gericht",
      "parentTrayId": 17,
      "documentCount": 5,
      "position": 1
    }
  ]
}

parentTrayId trägt die Verschachtelung: 0 heißt, das Register hängt direkt an der Akte – im Beispiel Schriftverkehr. Jeder andere Wert ist die trayId des übergeordneten Registers, hier hängt Gericht unter Schriftverkehr. Aus der Liste lässt sich damit der vollständige Registerbaum aufbauen; eine eigene Abfrage je Ebene ist nicht nötig, die Antwort enthält alle Register der Akte auf einmal. position ist die Reihenfolge unter Geschwisterregistern, so wie der Benutzer sie im Dokumentenmanagement sieht – nach ihr wird sortiert, nicht nach trayId oder Name. documentCount nennt die Zahl der Dokumente unmittelbar in diesem Register, ohne die seiner Unterregister.

Die Dokumente eines einzelnen Registers auflisten

curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/dms/documents/container/$CONTAINER/filing-tray/17/documents"

Die Antwort hat dieselbe Form wie die Dokumentliste der Akte. Standardmäßig enthält sie nur die Dokumente, die unmittelbar in diesem Register liegen. Mit recursive=true kommen die Dokumente der darunter verschachtelten Register dazu – im Beispiel also auch die aus Gericht:

curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/dms/documents/container/$CONTAINER/filing-tray/17/documents?recursive=true&pageSize=50"

Damit lässt sich ein einzelnes Register samt seiner Unterregister übernehmen, statt alle Dokumente der Akte zu holen und hinterher auszusortieren.

trayId=0 ist kein Register, sondern die Ablage direkt in der Akte: der Aufruf liefert die Dokumente, die in keinem Register einsortiert sind. recursive hat dort keine Wirkung, und in der Registerliste taucht 0 folgerichtig nicht auf.

Ein Dokument hochladen

Der Upload läuft in vier Schritten, weil die Dateibytes nicht durch die Schnittstelle selbst laufen: Ablageplatz anfordern, Datei ablegen, Import beauftragen, Ergebnis abholen.

Erstens: Ablageplatz anfordern

curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fileName":"Schriftsatz.pdf"}' \
  "$BASE/v1/platform/uploads/single"
{
  "objectKey": "kanzlei-mustermann/staging/9c1f.../Schriftsatz.pdf",
  "putUrl": "https://...s3.eu-central-1.amazonaws.com/...&X-Amz-Signature=...",
  "expiresAt": "2026-08-24T10:12:44Z"
}

Zweitens: Die Datei dort ablegen

Die beiden Werte aus der Antwort werden gleich noch gebraucht:

PUT_URL="https://...s3.eu-central-1.amazonaws.com/...&X-Amz-Signature=..."   # putUrl
OBJECT_KEY="kanzlei-mustermann/staging/9c1f.../Schriftsatz.pdf"              # objectKey

Der Aufruf geht direkt an die Ablage, ohne das Zugriffstoken – die Adresse ist bereits signiert und zeitlich befristet:

curl -sS -X PUT --upload-file ./Schriftsatz.pdf "$PUT_URL"

Für sehr große Dateien gibt es statt uploads/single die mehrteilige Variante unter platform/multipart/…, bei der die Datei in Teilen abgelegt und am Ende zusammengeführt wird.

Drittens: Den Import beauftragen

curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: import-schriftsatz-2026-0042-001" \
  -d '{
        "fileName": "Schriftsatz.pdf",
        "objectKey": "kanzlei-mustermann/staging/9c1f.../Schriftsatz.pdf",
        "containerId": "1f0c9d1e-6f3b-4a2c-9d20-6b0f4f8a1c77",
        "trayId": 17,
        "comment": "Eingang über Partneranwendung"
      }' \
  "$BASE/v1/dms/documents"

Die Antwort ist 202 Accepted:

{
  "operationId": "7f3a2b9e4c1d40e8a5b26f0913c47ade",
  "statusUrl": "/v1/platform/operations/7f3a2b9e4c1d40e8a5b26f0913c47ade"
}

202 heißt angenommen, nicht erledigt. Der Auftrag ist in der Cloud eingegangen und geprüft – mehr sagt diese Antwort nicht. Ob das Dokument tatsächlich im Dokumentenmanagement angekommen ist, steht erst im Ergebnis des Vorgangs. Dazwischen kann noch einiges schiefgehen: Der Connector kann die Verbindung verlieren, das Dokumentenmanagement kann die Rechte verweigern, der Import kann in eine Zeitüberschreitung laufen. Eine Integration, die nach dem 202 aufhört, meldet Erfolge, die keine sind.

Die operationId ist eine 32-stellige Hexadezimalkennung, kein selbst gewähltes Format. Sie wird von der Schnittstelle vergeben und unverändert weiterverwendet.

Viertens: Auf das Ergebnis warten

Die statusUrl aus der Antwort lässt sich direkt an $BASE hängen – genau dafür trägt $BASE die Version nicht und jeder Pfad sie selbst:

curl -sS -H "Authorization: Bearer $TOKEN" "$BASE$STATUS_URL"

Oder, gleichbedeutend, aus der operationId zusammengesetzt:

curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/platform/operations/7f3a2b9e4c1d40e8a5b26f0913c47ade"

Solange der Vorgang läuft:

{ "operationId": "7f3a2b9e4c1d40e8a5b26f0913c47ade", "status": "pending" }

Nach Abschluss:

{
  "operationId": "7f3a2b9e4c1d40e8a5b26f0913c47ade",
  "status": "succeeded",
  "result": {
    "documentId": "b41e77a0-2c33-4f9e-b0a1-5d70e2f6ac18",
    "importedBytes": 184320
  }
}

Bei einem Fehlschlag:

{
  "operationId": "7f3a2b9e4c1d40e8a5b26f0913c47ade",
  "status": "failed",
  "reason": "Das Dokumentenmanagement konnte den Auftrag nicht ausführen.",
  "errorCode": "TARGET_DMS_FAILURE"
}

errorCode kann auch null sein, wenn der Connector keinen stabilen Code gemeldet hat; reason ist dann die einzige Auskunft und nicht für eine Auswertung im Programm gedacht.

Wenn sich der Ausgang nicht feststellen lässt:

{
  "operationId": "7f3a2b9e4c1d40e8a5b26f0913c47ade",
  "status": "ambiguous",
  "reason": "Der Vorgang hat innerhalb seiner Verarbeitungsfrist kein Ergebnis gemeldet.",
  "errorCode": "OPERATION_TIMEOUT"
}

ambiguous ist kein Fehlschlag. failed heißt: der Vorgang wurde abgelehnt und hat nicht stattgefunden. ambiguous heißt: der Vorgang ist beendet, aber es lässt sich nicht feststellen, ob das DMS ihn ausgeführt hat — etwa weil die Verarbeitungsfrist ohne Ergebnis ablief (OPERATION_TIMEOUT) oder ein vorhandenes Ergebnis nicht lesbar ist (OPERATION_RESULT_UNREADABLE). Prüfen Sie dann im DMS nach, bevor Sie erneut senden: ein blindes erneutes Senden kann ein Dokument doppelt anlegen. Der Dienst rät den Ausgang nicht und wiederholt keine frühere Antwort.

Der Abrufzeitraum beträgt sieben Tage. Danach antwortet die Vorgangs-Adresse mit 404.

Ein sinnvoller Abfragerhythmus ist etwa jede Sekunde für die ersten zehn Sekunden, danach alle fünf Sekunden.

Ein Dokument herunterladen

Spiegelbildlich zum Upload:

DOC="b41e77a0-2c33-4f9e-b0a1-5d70e2f6ac18"

curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/dms/documents/$DOC/content"
{
  "operationId": "op_2d90ff41ab",
  "statusUrl": "/v1/platform/operations/op_2d90ff41ab"
}

Nach Abschluss liefert die Vorgangsabfrage die Download-Adresse:

{
  "operationId": "op_2d90ff41ab",
  "status": "succeeded",
  "result": {
    "downloadUrl": "https://...s3.eu-central-1.amazonaws.com/...&X-Amz-Signature=...",
    "downloadUrlExpiresAt": "2026-09-09T10:12:24Z",
    "sizeBytes": 184320,
    "fileName": "Schriftsatz.pdf",
    "version": 3,
    "rendition": null
  }
}
DOWNLOAD_URL="https://...s3.eu-central-1.amazonaws.com/...&X-Amz-Signature=..."   # result.downloadUrl

curl -sS -o Schriftsatz.pdf "$DOWNLOAD_URL"

Die Download-Adresse wird bei jeder Abfrage neu erzeugt und ist nur kurz gültig – bis zu dem Zeitpunkt, den result.downloadUrlExpiresAt nennt, in der Größenordnung von 15 Minuten. Sie sollte nicht zwischengespeichert werden – stattdessen die Vorgangsabfrage erneut aufrufen.

Nicht zu verwechseln mit transferDeadline. Das ist die Frist des Vorgangs selbst und sagt nur, wie lange abgefragt werden darf; sie liegt deutlich später und gilt nicht für die Download-Adresse.

Welche Versionen und Renditions es zu einem Dokument gibt, sagen die Lese-Endpunkte:

curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/v1/dms/documents/$DOC"            # Stammdaten
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/v1/dms/documents/$DOC/versions"   # Versionsverlauf
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/v1/dms/documents/$DOC/renditions" # Renditions

Die Stammdaten nennen unter anderem, in welcher Akte und welchem Register das Dokument liegt, und die Nummer der aktuellen Version:

{
  "documentId": "b41e77a0-2c33-4f9e-b0a1-5d70e2f6ac18",
  "title": "Rahmenvertrag",
  "documentClass": "stp.doc.contract",
  "filing": [{ "containerId": "1f0c9d1e-6f3b-4a2c-9d20-6b0f4f8a1c77", "trayId": 17 }],
  "latestVersion": 3,
  "versionCount": 3,
  "sizeBytes": 184320,
  "fileName": "Schriftsatz.pdf"
}

Die Antwort von /renditions nennt die Namen, die ?rendition= beim Abruf erwartet – und die Version, zu der sie gehören:

{
  "documentId": "b41e77a0-2c33-4f9e-b0a1-5d70e2f6ac18",
  "version": 3,
  "renditions": [
    { "rendition": "stp.doc.preview", "extension": "pdf", "sizeBytes": 20480 }
  ],
  "totalCount": 1
}

Damit lässt sich eine bestimmte Version über ?version=2 anfordern, eine Rendition – eine alternative Darstellung desselben Inhalts, etwa die PDF-Vorschau – über ?rendition=:

curl -sS -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/dms/documents/$DOC/content?rendition=stp.doc.preview"

Der Name der heruntergeladenen Datei richtet sich dann nach der Rendition, nicht nach dem Dokument: die PDF-Vorschau eines Vertrag.docx kommt als Vertrag.pdf an. rendition im Ergebnis nennt den angeforderten Namen. Gibt es die Rendition nicht, antwortet die Vorgangsabfrage mit failed und errorCode: "NOT_FOUND".

Renditions lassen sich nur abrufen. Diese Schnittstelle bietet keinen Weg, eine eigene Rendition zu hinterlegen.

Eine neue Version eines Dokuments einstellen

Genau wie beim Import: erst die neue Datei bereitstellen (POST /v1/platform/uploads/single, dann PUT auf die signierte Adresse – Schritt eins und zwei von oben), dann den Aufruf. Er hängt am Dokument statt an der Dokumentensammlung, und der objectKey ist der der neu bereitgestellten Datei – ein bereits importierter lässt sich nicht ein zweites Mal verwenden.

NEW_OBJECT_KEY="kanzlei-mustermann/staging/4b7e.../Schriftsatz.pdf"   # objectKey der neuen Datei

curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: version-b41e77a0-002" \
  -d '{"fileName":"Schriftsatz.pdf","objectKey":"'"$NEW_OBJECT_KEY"'","comment":"Nach Rücksprache korrigiert"}' \
  "$BASE/v1/dms/documents/$DOC/versions"

Auch das antwortet mit 202; die Vorgangsabfrage nennt anschließend die neue Version:

{
  "status": "succeeded",
  "result": {
    "documentId": "b41e77a0-2c33-4f9e-b0a1-5d70e2f6ac18",
    "version": 4,
    "importedBytes": 187001
  }
}

Akte, Register, Titel und Dokumentklasse bleiben unverändert – es ändert sich nur der Inhalt. Das comment beschreibt die neue Version; anders als beim Import eines neuen Dokuments wird daraus nicht der Titel des Dokuments, und ohne comment wird der Kommentar der bisherigen Version übernommen. Standardmäßig übernimmt das Dokument den neuen Dateinamen; mit "keepFileName": true behält es den bisherigen. Der fileName muss – wie beim Import – eine Endung tragen, sonst antwortet der Aufruf mit 400. Ein unbekanntes Dokument führt zu failed mit errorCode: "NOT_FOUND".

Ein Dokument, das gerade jemand anderes bearbeitet, wird nicht überschrieben. Ist es in Bearbeitung, zwischenzeitlich geändert, eingefroren oder zum Löschen vorgemerkt, endet der Vorgang mit errorCode: "CONFLICT" – dann das Dokument erneut lesen und den Aufruf wiederholen.

Ein Dokument umlegen

curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: move-doc-b41e77a0-001" \
  -d '{"targetContainerId":"3b21...","targetTrayId":4}' \
  "$BASE/v1/dms/documents/$DOC/move"

Diese Antwort kommt synchron:

{
  "documentId": "b41e77a0-2c33-4f9e-b0a1-5d70e2f6ac18",
  "previousContainerId": "1f0c9d1e-6f3b-4a2c-9d20-6b0f4f8a1c77",
  "previousTrayId": 17,
  "currentContainerId": "3b21...",
  "currentTrayId": 4
}

Zielakte und Zielregister werden vorab geprüft. Gibt es die Akte nicht, oder hat sie das angegebene Register nicht, ist die Antwort 404 NOT_FOUND und die Meldung benennt, was fehlt – nicht etwa ein Serverfehler, der zum Wiederholen einlädt. targetTrayId darf 0 sein: das legt das Dokument direkt in die Akte, in kein Register.

Dasselbe in C

Ein minimaler Client, der eine Akte sucht und ein Dokument importiert. Das Zugriffstoken stammt aus der Anmeldung und wird hier als bereits vorhanden angenommen.

using System.Net.Http.Headers;
using System.Net.Http.Json;

var http = new HttpClient
{
    BaseAddress = new Uri("https://<mandant>.stp-cloud.de/documents/dms-cloud-api/api/")
};
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
http.DefaultRequestHeaders.Add("X-Correlation-Id", Guid.NewGuid().ToString("N"));

// 1) Akte suchen
var search = await http.GetFromJsonAsync<SearchResponse>(
    "v1/dms/documents/container?searchTerm=Mustermann&pageSize=25");
var container = search!.Containers[0];

// 2) Ablageplatz anfordern
var slot = await (await http.PostAsJsonAsync(
        "v1/platform/uploads/single", new { fileName = "Schriftsatz.pdf" }))
    .Content.ReadFromJsonAsync<UploadSlot>();

// 3) Datei direkt in die Ablage schreiben - ohne Token, die Adresse ist signiert
using (var raw = new HttpClient())
using (var content = new StreamContent(File.OpenRead("Schriftsatz.pdf")))
{
    (await raw.PutAsync(slot!.PutUrl, content)).EnsureSuccessStatusCode();
}

// 4) Import beauftragen - derselbe Schlüssel bei jedem Wiederholungsversuch
var import = new HttpRequestMessage(HttpMethod.Post, "v1/dms/documents")
{
    Content = JsonContent.Create(new
    {
        fileName = "Schriftsatz.pdf",
        objectKey = slot.ObjectKey,
        containerId = container.ContainerId,
        trayId = 17,
    }),
};
import.Headers.Add("Idempotency-Key", "import-schriftsatz-2026-0042-001");

var accepted = await (await http.SendAsync(import))
    .Content.ReadFromJsonAsync<AcceptedResponse>();

// 5) Ergebnis abholen - mit Abbruch, sonst läuft die Schleife bei einer
//    Störung endlos weiter
OperationResponse status;
var deadline = DateTimeOffset.UtcNow.AddMinutes(10);
while (true)
{
    await Task.Delay(TimeSpan.FromSeconds(1));
    status = (await http.GetFromJsonAsync<OperationResponse>(
        $"v1/platform/operations/{accepted!.OperationId}"))!;

    if (status.Status != "pending")
    {
        break;
    }

    if (DateTimeOffset.UtcNow > deadline)
    {
        throw new TimeoutException(
            $"Vorgang {accepted.OperationId} ist nach 10 Minuten noch pending.");
    }
}

Console.WriteLine(status.Status == "succeeded"
    ? $"Importiert als {status.Result!.Value.GetProperty("documentId")}"
    : $"Fehlgeschlagen: {status.ErrorCode} - {status.Reason}");

Die vier Datenklassen dazu:

using System.Text.Json;

sealed record SearchResponse(List<ContainerItem> Containers, int? TotalCount,
                             string? NextContinuationToken);

sealed record ContainerItem(string ContainerId, string Name, string? FileReference,
                            string? Type);

sealed record UploadSlot(string ObjectKey, string PutUrl, DateTimeOffset ExpiresAt);

sealed record AcceptedResponse(string OperationId, string StatusUrl);

sealed record OperationResponse(string OperationId, string Status, string? Reason,
                                string? ErrorCode, JsonElement? Result);

HttpClient bindet die Namen ohne weiteres Zutun, weil die Schnittstelle in camelCase antwortet und der Standardvergleich Groß- und Kleinschreibung ignoriert.

In einer produktiven Integration kommen zwei Dinge hinzu, die dieses Beispiel der Kürze halber auslässt:

  • Wiederholungen mit Wartezeitverlängerung bei den Antwortcodes 429, 502, 503 und 504 – siehe Idempotenz und Wiederholungen.
  • Auswertung des Fehlerumschlags, um code und supportCode mitzuprotokollieren – siehe Fehlercodes.

Ein Wiederholungsversuch liefert nicht das ursprüngliche Ergebnis. Wird derselbe Idempotency-Key erneut geschickt, antwortet die Schnittstelle mit 409 IDEMPOTENCY_KEY_REUSED bzw. 504 IDEMPOTENCY_KEY_INFLIGHT_TIMEOUT – der Auftrag wird also kein zweites Mal ausgeführt, aber sein Ausgang wird auch nicht nachgeliefert. Den muss die Anwendung über die Vorgangsabfrage klären.

Weiterführend

Verknüpfung mit