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.

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

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}
    />
  )
}

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.