File uploads
Some user tasks expect documents rather than a structured payload — typically when an applicant needs to send in proof of income, a contract, or a similar supporting file. Instead of cramming the binary into the task input, the API separates the two concerns: upload the file first, then complete the task with the resulting file IDs.
This guide covers the two-step pattern and shows how to handle a flow that asks for several documents in a row.
The samples below are written as plain fetch calls so they're easy to
port. In a real integration you'll likely wrap them in your own client or
use something like tanstack-query to
handle loading states, retries, and cache invalidation.
Step 1: upload the file
Files are sent as multipart/form-data to the file endpoint. The response
gives you a fileId per file, which you'll attach to the task input in the
next step.
async function uploadFile(file: File) {
const body = new FormData()
body.append('file', file)
const response = await fetch('/api/files', {
method: 'POST',
body,
// Don't set Content-Type manually — the browser adds the multipart
// boundary for you.
})
if (!response.ok) throw new Error('Failed to upload file')
return response.json() // { fileId, filename, contentType, size }
}
We recommend doing the upload from a backend you control rather than straight from the browser, the same way we recommend for the rest of the API. That keeps credentials off the client and lets you enforce file-size and content-type limits before the bytes ever hit our endpoint.
Step 2: complete the task with the file IDs
User tasks that expect documents take an array of fileIds as their input.
Once your file is uploaded, complete the task the same way you would any
other:
async function completeUploadTask(taskId: string, fileIds: string[]) {
const response = await fetch(`/api/tasks/${taskId}/complete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ fileIds }),
})
if (!response.ok) throw new Error('Failed to complete task')
}
If a single task allows multiple attachments, upload each file first and then complete the task once with all the ids:
const uploaded = await Promise.all(files.map(uploadFile))
await completeUploadTask(task.taskId, uploaded.map((u) => u.fileId))
Handling several documents in the same flow
A flow can ask for more than one document. When that happens, the process
spawns a new user task for each document it wants, and task.context tells
you which document type that particular task is asking for.
You don't need to track this yourself — the polling pattern from the
overview does it for you. Each new pending task is a new
upload to perform, with its own taskId. Treat them one at a time: upload
the file, complete the task, and the next task will show up on the next
poll.
function UploadStep({ task, onComplete }) {
const requestedDocument = task.context?.document ?? 'GENERIC'
async function handleSubmit(files: File[]) {
const uploaded = await Promise.all(files.map(uploadFile))
await completeUploadTask(
task.taskId,
uploaded.map((u) => u.fileId),
)
onComplete()
}
return (
<FileDropzone
label={`Please upload your ${requestedDocument.toLowerCase()} document`}
onSubmit={handleSubmit}
/>
)
}
task.context.document is a short code (for example INCOME,
STUDENTLOAN, INSURANCE) so you can map it to your own copy and
illustrations. The set of codes a given task can produce is documented on
the task's schema page.
What happens next
Uploaded files are attached to the flow's state. Downstream tasks — for
instance the case worker's review task — read them out of their own
context and can fetch the file bytes by fileId when needed. From the
applicant's side, once you've completed the task, you're done; the next
poll will either deliver another upload task or move you to a different
step.