This article walks through a complete workflow: finding a case file,
uploading a document, and downloading a document. First using
curl so each step is clearly visible, then the same thing
in C#.
It's aimed at 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..."Every request requires a
User-Agentheader. STP Cloud's bot protection rejects requests without one with a403before they even reach the API — with no error body returned.curlsends its own value automatically, so the examples below work without any extra flags. In your application, set a value that identifies it, such asMyApplication/1.4.2(withcurl, use-A "MyApplication/1.4.2").HttpClientin .NET does not send one by default — the C# example below shows where to set it.
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 |
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 contains a value, more pages are
available. Pass it unchanged as continuationToken in the
next request.
A pagination token belongs to the exact query that produced it. If the search criteria change between pages — for example, because the user kept typing in the search field — the old token is invalid and the response will be
400 VALIDATION_FAILED. In that case, start a new search without acontinuationToken. The same applies to document lists: a token from one case file is not valid in another, and not in a different section either. Sending a criterion that is empty or contains only whitespace will also be rejected with400— 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 call:
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 captures 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. The full section tree can be built from this
list — no separate query per level is needed, as the response contains all
sections of the case file at once. position is the sort order
among sibling sections, as the user sees them in the document management
system — sort by this field, not by trayId or name.
documentCount is the number of documents directly in that
section, not including those in 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 structure as the case file's document list.
By default, it only includes documents directly in that section. Adding
recursive=true also includes documents from nested subsections
— in this example, that means 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 its subsections, rather than fetching all documents from the case file and filtering afterward.
trayId=0 is not a section but the case file's root
level: the call returns documents that haven't been filed into any section.
recursive has no effect there, and 0 accordingly
does not appear in the section list.
Uploading a document
Uploading happens in four steps, because the file bytes don't flow through the API itself: request a storage slot, upload the file, trigger the import, then retrieve the result.
Step 1: Request a storage 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 to that location
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" # objectKeyThis call goes directly to storage, 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 parts 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"
}
202means 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 may lose its connection, the document management system may deny access, or the import may time out. An integration that stops after the202will report successes that never happened.
The
operationIdis 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 carries it
itself:
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE$STATUS_URL"Or, equivalently, constructed from the operationId:
curl -sS -H "Authorization: Bearer $TOKEN" \
"$BASE/v1/platform/operations/7f3a2b9e4c1d40e8a5b26f0913c47ade"While the operation is still running:
{ "operationId": "7f3a2b9e4c1d40e8a5b26f0913c47ade", "status": "pending" }Once 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"
}
ambiguousis not a failure.failedmeans the operation was rejected and did not take place.ambiguousmeans the operation has ended, but it cannot be determined whether the DMS actually carried it out — for example, because the processing deadline passed without a result (OPERATION_TIMEOUT), or because an existing result could not be read (OPERATION_RESULT_UNREADABLE). In that case, check the DMS before resubmitting: blindly resubmitting can create a duplicate document. The service does not guess the outcome or replay an earlier response.
The retrieval window is seven days. After that, the operation URL responds with
404.
A sensible polling interval is roughly once per second for the first ten seconds, then 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 result contains 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, roughly on the order of 15 minutes. It should not be cached; instead, call the operation endpoint again to get a new one.Do not confuse this with
transferDeadline. That is the deadline for the operation itself and only indicates how long you can poll for it; it falls much 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" # RenditionsThe 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 the
?rendition= parameter when downloading, 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, and 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 reflects the rendition rather than the
original document: the PDF preview of a Vertrag.docx is
delivered as Vertrag.pdf. The rendition field
in the result contains the requested rendition name. If the rendition
doesn't exist, the operation result returns failed with
errorCode: "NOT_FOUND".
Renditions can only be retrieved. This API does not provide a way to upload your own 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 from 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 — one that has already been imported cannot be reused.
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 result 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. If no comment is provided, the
comment from the previous version is carried over. By default, the document
takes on the new file name; use "keepFileName": true to retain
the existing one. The fileName must include a file extension —
as with imports — otherwise the call returns 400. An unknown
document results in failed with
errorCode: "NOT_FOUND".
A document currently being edited by someone else will not be overwritten. If it is checked out, has been modified in the meantime, is locked, or is marked for deletion, the operation ends with
errorCode: "CONFLICT"— in that case, re-read the document and retry the call.
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 identifies what's missing —
not a generic server error inviting a retry.
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 be already available from the authentication step.
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);
// Required: without a User-Agent, bot protection rejects every request with 403
http.DefaultRequestHeaders.UserAgent.ParseAdd("MeineAnwendung/1.4.2");
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 storage 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 on error
// 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 used here:
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 binds the names automatically because the API
responds in camelCase and the default comparison is case-insensitive.
The User-Agent header is required on every
client that talks to STP Cloud — including the one your application uses to
obtain and refresh tokens. It is not needed for the client that writes to
the signed storage URL (raw above).
A production integration would add two things that this example omits for brevity:
-
Retries with exponential backoff for response codes
429,502,503, and504— see Idempotency and retries. -
Parsing the error envelope to log
codeandsupportCode— see Error codes.
A retry does not return the original result. Resending the same
Idempotency-Keycauses the API to respond with409 IDEMPOTENCY_KEY_REUSEDor504 IDEMPOTENCY_KEY_INFLIGHT_TIMEOUT— the request will not be executed a second time, and the outcome of the original request is not included in the response. Your application must check the outcome via the operation endpoint.
Further reading
This article has been automatically translated by an AI and may therefore contain errors.
Related to