File Variables
Process instances hold data in variables. POST /v1/instances/{id}/variables — the endpoint you'll typically use to read and write them by hand — enforces a payload cap of 5 MB serialized (MAX_VARIABLES_PAYLOAD_BYTES) on the instance's full variables blob (existing variables merged with the patch you send), not just the patch itself. Embedding a binary file as base64 in a variable counts fully against that budget and inflates every payload that touches the instance through this endpoint — the variables blob, audit snapshots, webhook deliveries.
For anything beyond a small embedded value, upload the file directly to object storage instead. The client uploads straight to a pre-signed URL — the file body never passes through the Stateway API — and the instance variable ends up holding a small reference object (a file_ref) instead of the bytes themselves.
If you just need a small blob (a signature, a thumbnail) inline with the rest of your process data, embedding base64 in a regular variable still works and is documented in Inline Types → Binary and File Data. This guide covers the Files API, which is the right tool once the payload is a real file.
The Upload Flow
Uploading a file is a three-step handshake: request a slot, upload to storage, confirm.
1. Request an upload slot
curl -X POST https://api.stateway.io/v1/instances/{instance_id}/files \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"variable_name": "contract_pdf",
"filename": "contract.pdf",
"mime_type": "application/pdf",
"size_bytes": 154213
}'
variable_name is the instance variable that will hold the file_ref once you confirm the upload — there is no BPMN-level declaration involved; see Where files stand in the type system below. mime_type must be a concrete type such as application/pdf; wildcards like application/* are rejected. filename may not contain slashes, backslashes, or control characters, and is capped at 255 characters.
Response:
{
"data": {
"ref_id": "fr_5f2c9a1e8b3d4c6f9a0b1c2d3e4f5061",
"upload_url": "https://storage.example.com/stateway-prod/tenants/.../contract.pdf?X-Amz-Signature=...",
"expires_at": "2026-08-02T10:35:00.000Z",
"method": "PUT",
"headers": {
"Content-Type": "application/pdf",
"Content-Length": "154213"
}
}
}
This response also carries X-Stateway-Storage-Used and X-Stateway-Storage-Quota headers — see Limits and quota.
upload_url is valid until expires_at (5 minutes by default). headers are the exact headers the client must send on the PUT.
2. Upload directly to storage
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/pdf" \
-H "Content-Length: 154213" \
--data-binary @contract.pdf
The file body goes straight from the client to the storage backend. The Stateway API never sees it, and it never counts toward any API request size limit.
3. Confirm the upload
curl -X POST https://api.stateway.io/v1/instances/{instance_id}/files/fr_5f2c9a1e8b3d4c6f9a0b1c2d3e4f5061/confirm \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"metadata": { "source": "customer-portal" }
}'
metadata is optional, free-form business data merged into the file_ref once confirmed. Confirmation verifies the uploaded object in storage (existence and size) before it populates the variable — until this call succeeds, contract_pdf does not exist as an instance variable at all.
Response:
{
"data": {
"variable_name": "contract_pdf",
"file_ref": {
"$type": "file_ref",
"ref_id": "fr_5f2c9a1e8b3d4c6f9a0b1c2d3e4f5061",
"filename": "contract.pdf",
"mime_type": "application/pdf",
"size_bytes": 154213,
"storage": "s3",
"uploaded_at": "2026-08-02T10:31:04.000Z",
"checksum_sha256": null,
"metadata": { "source": "customer-portal" }
}
}
}
The file_ref Variable Value
Once confirmed, the variable holds a file_ref object with these fields:
| Field | Description |
|---|---|
$type | Always "file_ref" |
ref_id | Stable identifier for the file reference |
filename | Original filename as declared at slot creation |
mime_type | MIME type as declared at slot creation |
size_bytes | Measured size in storage (not just the declared value) |
storage | Storage backend identifier (currently always "s3") |
uploaded_at | ISO 8601 timestamp of confirmation |
checksum_sha256 | SHA-256 checksum if the storage backend returned one, otherwise null |
metadata | The free-form object passed to confirm, only present if non-empty |
object_key and bucket are internal storage details. They're stripped everywhere a variable's current value is returned or delivered as a managed representation — including nested file_ref values inside other variables: REST responses for an instance's or task's current variables, WebSocket events, webhook notification payloads (e.g. service_task.completed), the webhook delivery-history endpoint (including deliveries recorded before this protection existed), the outbound request body a serviceTask/sendTask sends to the external URL configured via its I/O mapping, and the decision evaluation history endpoints (inputVariables, outputVariables, and matchedRules).
Two narrow exceptions remain. The audit history endpoint only strips variable snapshots recorded after this protection was added — snapshots recorded earlier retain the fields. And when a businessRuleTask evaluates a DMN decision table, the raw, unprojected variable value is what the decision table actually sees and computes with while evaluating — stripping it beforehand would silently change what the table's conditions and outputs can see, which would be a correctness bug rather than a privacy improvement. This only affects that live evaluation input itself: everything the evaluation produces or is later exposed as — the decision evaluation history endpoints and the webhook notification sent afterward — strips the fields the same as everywhere else.
Separately, the pre-signed download URL and the redirect Location necessarily carry the storage path in the URL itself — that's how a pre-signed URL grants access. The key always belongs to the calling tenant's own storage layout, so this isn't a cross-tenant leak; it just means the storage path isn't secret once you have a valid download link.
Downloading a File
GET /v1/instances/{id}/variables/{name}/download redirects (302) to a pre-signed, temporary download URL:
curl -i https://api.stateway.io/v1/instances/{instance_id}/variables/contract_pdf/download \
-H "X-API-Key: sw_live_your_key"
HTTP/1.1 302 Found
location: https://storage.example.com/stateway-prod/tenants/.../contract.pdf?X-Amz-Signature=...
x-stateway-file-ref: fr_5f2c9a1e8b3d4c6f9a0b1c2d3e4f5061
x-stateway-filename: contract.pdf
x-stateway-mime-type: application/pdf
x-stateway-filename is percent-encoded UTF-8 (RFC 3986) — decode it before using it, since raw HTTP headers cannot carry non-ASCII bytes. The redirect target is valid for 5 minutes by default. This response does not carry the storage-quota headers described below — a download does not change how much storage the tenant is using.
Listing variables with download links
GET /v1/instances/{id}/variables?include_download_url=true attaches a download_url field to every variable that is a confirmed file_ref:
curl "https://api.stateway.io/v1/instances/{instance_id}/variables?include_download_url=true" \
-H "X-API-Key: sw_live_your_key"
{
"data": {
"contract_pdf": {
"$type": "file_ref",
"ref_id": "fr_5f2c9a1e8b3d4c6f9a0b1c2d3e4f5061",
"filename": "contract.pdf",
"mime_type": "application/pdf",
"size_bytes": 154213,
"storage": "s3",
"uploaded_at": "2026-08-02T10:31:04.000Z",
"checksum_sha256": null,
"download_url": "https://storage.example.com/stateway-prod/tenants/.../contract.pdf?X-Amz-Signature=..."
}
}
}
A variable whose upload has not been confirmed yet, or that has already been deleted, simply has no download_url field in this listing — it is not an error, since one pending or deleted file among many variables shouldn't fail the whole listing. Use the dedicated download endpoint above if you need the precise reason.
Deleting a File (LGPD/GDPR)
DELETE /v1/instances/{id}/variables/{name} removes the object from storage and replaces the variable with a tombstone:
curl -X DELETE https://api.stateway.io/v1/instances/{instance_id}/variables/contract_pdf \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "reason": "customer requested erasure" }'
reason is optional free text (1–255 characters); it defaults to "unspecified" when omitted.
Response:
{
"data": {
"variable_name": "contract_pdf",
"status": "deleted",
"tombstone": {
"$type": "file_ref_deleted",
"ref_id": "fr_5f2c9a1e8b3d4c6f9a0b1c2d3e4f5061",
"deleted_at": "2026-08-02T11:00:00.000Z",
"deleted_by": {
"actor_type": "api_key",
"actor_id": "...",
"tenant_id": "..."
},
"reason": "customer requested erasure"
}
}
}
The deletion is idempotent: repeating the same DELETE call returns the same tombstone rather than an error. After deletion, the download endpoint responds 410 for that variable, with the tombstone in the error details.
Limits and Quota
| Limit | Default | Notes |
|---|---|---|
| Max file size | 20 MB | MAX_FILE_SIZE_BYTES; enforced when requesting the upload slot |
| Upload URL validity | 5 minutes | FILE_UPLOAD_URL_TTL_SECONDS; the deployment may configure 60–600 seconds |
| Confirmation grace period | 60 seconds after the upload URL expires | Confirming after this window returns 410 UPLOAD_EXPIRED |
| Download URL validity | 5 minutes | FILE_DOWNLOAD_URL_TTL_SECONDS; the deployment may configure 60–600 seconds |
| Declared vs. measured size tolerance | 1 byte | Larger mismatches fail confirmation with 422 |
| Storage quota per tenant | 5 GB | Shared across all instances of the tenant; contact support to increase it |
The three mutating endpoints — request slot, confirm, delete — return X-Stateway-Storage-Used and X-Stateway-Storage-Quota response headers (bytes), reflecting the tenant's storage usage immediately after the operation. The download redirect does not return these headers.
Exceeding the storage quota fails the operation with 402 STORAGE_QUOTA_EXCEEDED, whether at slot creation or — if another upload consumed the remaining quota in the meantime — at confirmation.
Errors
| Status | code | Endpoint(s) | Meaning |
|---|---|---|---|
| 400 | VALIDATION_ERROR | Request slot, Confirm, Delete | Malformed body, size_bytes exceeds the max file size, mime_type is a wildcard, or reason is empty/too long |
| 402 | STORAGE_QUOTA_EXCEEDED | Request slot, Confirm | The tenant's storage quota is exhausted |
| 404 | NOT_FOUND | Request slot, Confirm, Download, Delete | Instance, tenant, variable, or file reference not found |
| 409 | CONFLICT | Request slot, Confirm, Delete | Instance not running/suspended (slot); upload already confirmed (confirm); or the variable pointed at a different file reference while the deletion was in flight — retry (delete) |
| 410 | FILE_REF_DELETED | Confirm, Download | The file was already deleted (tombstone in details, or just the ref_id if the variable itself is stale) |
| 410 | UPLOAD_EXPIRED | Confirm | The upload slot expired past its grace period |
| 422 | PROCESS_ERROR | Confirm | The uploaded object is missing from storage, or its measured size doesn't match size_bytes beyond the 1-byte tolerance |
| 422 | FILE_REF_PENDING | Download, Delete | The upload hasn't been confirmed yet |
| 422 | NOT_A_FILE_VARIABLE | Delete | The named variable exists but isn't a file_ref |
| 422 | VARIABLE_TYPE_MISMATCH | Request slot | The target variable has a declared type that isn't a stateway:file scalar (a different type, or a stateway:file collection) |
Where Files Stand in the Type System
You can, optionally, declare a variable's BPMN type as stateway:file:
<bpmn:itemDefinition id="ContractDocumentType" structureRef="stateway:file" />
<bpmn:dataObject id="ContractDoc" name="contract_document" itemSubjectRef="ContractDocumentType" />
This doesn't change how the upload flow works — the link between a file and the process is still made purely by the variable_name you pass when requesting the upload slot, whether or not that variable has a declared type. What declaring the type adds:
- Shape validation. The variable only accepts a
file_ref(see above) or its deletion tombstone — any other value is rejected the same way any other typed variable's value would be. - A guard at the upload slot. Requesting a slot for a variable declared with a different type (or a
stateway:filecollection — the Files API doesn't support appending to collections yet) fails with422 VARIABLE_TYPE_MISMATCHbefore anything is uploaded.
A variable with no declared type still accepts uploads exactly as before — declaring stateway:file is optional, not a prerequisite for using this API.
See Inline Types → Binary and File Data for how this compares to embedding base64 data in a typed variable.
Retention
Files that belong to instances that have already completed or been terminated are automatically removed from storage, with a tombstone left in their place, after a retention period of approximately 5 years — the exact cutoff includes a short additional grace window, so a file may be retained a little past the 5-year mark before removal. This is separate from on-demand deletion above — it exists as a backstop so storage isn't retained indefinitely for instances no one is acting on anymore.