Quick Start with curl and CSharp

This article walks through a complete workflow: finding a case file, uploading a document, and downloading a document. First using curl so every step is clearly visible, then the same thing in C#.

It's intended for anyone building their own integration against the API. You'll need a registered application and a valid access token — both are covered in Connecting Your Own Application.

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

Finding a Case File or Folder

Three mutually exclusive search options are available:

Parameter Behavior
name Exact search by case file name
fileReference Exact search by file reference number
searchTerm Prefix search across both
(none of the above) Page through all case files — or, with containerType=folder, through all personal folders instead
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
}

If nextContinuationToken has a value, there are more pages. Pass it unchanged as continuationToken in your next request.

A page token belongs to exactly the query that produced it. If the search criteria change between pages — for example, because the user kept typing in a search field — the old token is no longer valid and the response will be 400 VALIDATION_FAILED. In that case, start the search again without continuationToken. The same applies to document lists: a token from one case file is not valid in another, nor in a different section. Sending a criterion that is empty or contains only whitespace will also be rejected with 400 — if you want to page through everything, simply omit the parameter.

Listing the Documents in a Case File

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
}

Reading the Section Structure of a Case File

The document list above is flat: it lists all documents in the case file but doesn't indicate which section they belong to. The case file's internal structure comes from a separate request:

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 encodes the nesting: 0 means the section is directly attached to the case file — in this example, Schriftverkehr. Any other value is the trayId of the parent section; here, Gericht is nested under Schriftverkehr. From this list you can reconstruct the full section tree — no separate request per level is needed, as the response returns all sections of the case file at once. position is the display order among sibling sections, exactly as the user sees them in the document management system — sort by this field, not by trayId or name. documentCount gives the number of documents directly in that section, not counting any subsections.

Listing the Documents in a Single Section

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

The response has the same format as the case file's document list. By default, it only includes documents directly in that section. Adding recursive=true also pulls in documents from nested subsections — in this example, that includes documents from Gericht as well:

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

This lets you retrieve a single section along with all its subsections, rather than fetching all documents in the case file and filtering afterward.

trayId=0 is not a section — it represents documents filed directly in the case file: the request returns documents that haven't been assigned to any section. recursive has no effect there, and accordingly 0 does not appear in the section list.

Uploading a Document

The upload process involves four steps, because the file bytes don't flow through the API itself: request a staging slot, upload the file, trigger the import, then retrieve the result.

Step 1: Request a Staging Slot

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

Step 2: Upload the File There

You'll need both values from the response in a moment:

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

This request goes directly to the storage location, without the access token — the URL is already signed and time-limited:

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

For very large files, use the multipart variant at platform/multipart/… instead of uploads/single, which uploads the file in chunks and reassembles them at the end.

Step 3: Trigger the Import

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"

The response is 202 Accepted:

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

202 means accepted, not completed. The request has been received and validated by the cloud — that's all this response tells you. Whether the document actually made it into the document management system is only known once the operation result is available. A lot can still go wrong in between: the connector might lose its connection, the document management system might deny access, or the import might time out. An integration that stops after the 202 is reporting successes that may not be real.

The operationId is a 32-character hexadecimal identifier, not a format you choose yourself. It is assigned by the API and used as-is.

Step 4: Wait for the Result

The statusUrl from the response can be appended directly to $BASE — that's exactly why $BASE does not include the version, and each path includes it itself:

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

Or equivalently, built from the operationId:

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

While the operation is still in progress:

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

Once it's complete:

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

On failure:

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

errorCode may also be null if the connector didn't report a stable code; in that case reason is the only information available and is not intended for programmatic evaluation.

When the outcome cannot be determined:

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

ambiguous is not the same as a failure. failed means the operation was rejected and did not take place. ambiguous means the operation has ended, but it's impossible to tell whether the DMS actually carried it out — for example because the processing window expired without a result (OPERATION_TIMEOUT), or because an existing result could not be read (OPERATION_RESULT_UNREADABLE). Check the DMS directly before resending: blindly retrying can create a duplicate document. The service does not guess at the outcome and does not replay earlier responses.

The retrieval window is seven days. After that, the operation URL returns 404.

A reasonable polling interval is approximately once per second for the first ten seconds, then once every five seconds.

Downloading a Document

The mirror image of the 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"
}

Once complete, the operation query returns the download URL:

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

The download URL is freshly generated on every request and is only valid for a short time — until the timestamp given in result.downloadUrlExpiresAt, typically around 15 minutes. It should not be cached; instead, call the operation query again to get a fresh one.

Don't confuse this with transferDeadline. That is the deadline for the operation itself and only indicates how long you can poll for it; it is considerably later and does not apply to the download URL.

To find out which versions and renditions are available for a document, use the read endpoints:

curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/v1/dms/documents/$DOC"            # Master data
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/v1/dms/documents/$DOC/versions"   # Version history
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/v1/dms/documents/$DOC/renditions" # Renditions

The master data includes, among other things, which case file and section the document belongs to, and the current version number:

{
  "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"
}

The /renditions response lists the names expected by ?rendition= when fetching, along with the version they belong to:

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

You can request a specific version using ?version=2, or a rendition — an alternative representation of the same content, such as a PDF preview — using ?rendition=:

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

The downloaded file's name is then determined by the rendition, not the document: the PDF preview of a Vertrag.docx arrives as Vertrag.pdf. The rendition field in the result shows the name that was requested. If the rendition doesn't exist, the operation query responds with failed and errorCode: "NOT_FOUND".

Renditions can only be retrieved. This API provides no way to upload or store a custom rendition.

Uploading a New Version of a Document

The process is the same as for an import: first stage the new file (POST /v1/platform/uploads/single, then PUT to the signed URL — steps one and two above), then make the call. It targets the document rather than the document collection, and the objectKey must be the one for the newly staged file — a key that has already been imported cannot be used again.

NEW_OBJECT_KEY="kanzlei-mustermann/staging/4b7e.../Schriftsatz.pdf"   # objectKey of the new file

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"

This also responds with 202; the operation query then reports the new version:

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

The case file, section, title, and document class remain unchanged — only the content is updated. The comment describes the new version; unlike when importing a new document, it does not become the document title, and if comment is omitted, the comment from the previous version is carried over. By default, the document takes on the new file name; use "keepFileName": true to keep the existing one. The fileName must include a file extension — just like during import — otherwise the request will return 400. An unknown document results in failed with errorCode: "NOT_FOUND".

A document that someone else is currently editing will not be overwritten. If it is checked out, has been modified in the meantime, is frozen, or is marked for deletion, the operation ends with errorCode: "CONFLICT" — in that case, re-read the document and retry the request.

Moving a Document

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"

This response is synchronous:

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

The target case file and target section are validated upfront. If the case file doesn't exist, or doesn't have the specified section, the response is 404 NOT_FOUND and the message specifies what's missing — not a server error that would suggest retrying. targetTrayId may be 0, which files the document directly in the case file with no section.

The Same Thing in C#

A minimal client that searches for a case file and imports a document. The access token is assumed to come from the authentication process and to already be available.

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) Search for a case file
var search = await http.GetFromJsonAsync<SearchResponse>(
    "v1/dms/documents/container?searchTerm=Mustermann&pageSize=25");
var container = search!.Containers[0];

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

// 3) Write the file directly to storage - no token needed, the URL is already signed
using (var raw = new HttpClient())
using (var content = new StreamContent(File.OpenRead("Schriftsatz.pdf")))
{
    (await raw.PutAsync(slot!.PutUrl, content)).EnsureSuccessStatusCode();
}

// 4) Trigger the import - use the same key on every retry
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) Retrieve the result - include a timeout, otherwise the loop runs forever
//    if something goes wrong
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}");

The four data classes needed for this:

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 maps the property names automatically because the API responds in camelCase and the default comparison is case-insensitive.

A production integration would add two things that this example omits for brevity:

  • Retries with exponential backoff for response codes 429, 502, 503, and 504 — see Idempotency and Retries.
  • Parsing the error envelope to log code and supportCode — see Error Codes.

A retry does not return the original result. If the same Idempotency-Key is sent again, the API responds with 409 IDEMPOTENCY_KEY_REUSED or 504 IDEMPOTENCY_KEY_INFLIGHT_TIMEOUT — the request will not be executed a second time, but the outcome of the original request won't be returned either. Your application needs to look that up via the operation query.

Further Reading

------------------------------------------------------------------------------------------------------------------
This article has been automatically translated by an AI and may therefore contain errors.

Related to