WebApps File Uploads

Document information
  • Canonical URL: /docs/05_apps-webhooks-and-surfaces/webapps/47-Webapps_File_Uploads
  • Version: 2026-05-28
  • Tags: webapps, reference, webapps-file-uploads

This guide explains how to accept file uploads in a WebApp and process those files in ProcessFlow. It covers simple browser uploads, optional chunked uploads with Plupload, and how uploaded files appear in step code through the injected files service.

WebApp file upload flow showing browser multipart form submission, tenant-scoped storage metadata, ProcessFlow access via files service, and validated processing output.

Understand the upload lifecycle

There are two ways to get files into a WebApp run:

  1. Single request (recommended for most forms) — The browser sends one multipart/form-data POST with form fields and file parts. The WebApp stores the files, attaches metadata to the process input, and starts your ProcessFlow.
  2. Chunked upload (large files or slow networks) — The browser sends many POST requests (one per chunk) to the same published WebApp URL, then sends a final POST with form fields only. All requests for one submission must share the same _execution_id so chunks assemble into one folder before the process runs.

In both cases, ProcessFlow step code works the same way: use the files service to list and read uploads. Your steps should validate type, size, and business rules; the browser only delivers bytes.

Limits to plan for

LimitTypical valueNotes
Max size per fileUp to 1 GiBSingle multipart upload or assembled chunked file
Max chunk payload8 MiBEach chunked POST must stay within this size
Max files per submission50Across one _execution_id

Exact limits can vary by environment. If a request is rejected, the JSON response includes a message you can show to the user.

Configure the WebApp form (simple upload)

A working upload form must use method="post" and enctype="multipart/form-data" with one or more file inputs.

<form id="uploadForm" method="post" enctype="multipart/form-data">
  <label for="full_name">Full name</label>
  <input id="full_name" name="full_name" type="text" required />

  <label for="attachments">Attachments</label>
  <input id="attachments" name="attachments[]" type="file" multiple />

  <button type="submit">Upload</button>
</form>
<p id="result" aria-live="polite"></p>

Submit with fetch and FormData to your published WebApp URL (the same URL users open in the browser):

const form = document.getElementById("uploadForm");
const resultEl = document.getElementById("result");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const formData = new FormData(form);

  try {
    const response = await fetch(window.location.href, {
      method: "POST",
      body: formData,
    });
    const payload = await response.json();

    if (payload.success) {
      resultEl.textContent = "Upload successful.";
    } else {
      resultEl.textContent =
        payload?.error?.message ?? payload?.message ?? "Upload failed.";
    }
  } catch (error) {
    resultEl.textContent = `Network error: ${error.message}`;
  }
});

Use this pattern when the total upload fits comfortably in one request and your users have reliable connectivity.

Chunked uploads with Plupload

Chunked uploads split each file into smaller parts. Use them when files are large, mobile networks are unstable, or you want per-file progress bars before the user submits the rest of the form.

Contract (all chunk requests)

POST to your published WebApp URL with multipart/form-data. Each chunk request must include:

FieldDescription
chunkZero-based index of this chunk (0chunks - 1)
chunksTotal number of chunks for this file
unique_idStable id for this file upload (same value for every chunk of the same file)
nameOriginal file name
fileBinary chunk body (standard file field)
_execution_idSame value on every chunk and on the final form submit for this user session

Optional: you may send additional form fields on chunk requests; they are ignored until the final process submission.

Chunk responses

While chunks are still arriving:

{
  "success": true,
  "completed": false,
  "chunk": 2,
  "chunks": 10,
  "uploaded_chunks": 3
}

When the last chunk is stored and the file is assembled:

{
  "success": true,
  "completed": true,
  "file": {
    "field_name": "file",
    "original_name": "report.pdf",
    "stored_name": "file_1716890123456_report.pdf",
    "relative_path": "processdata/<execution_id>",
    "size": 52428800,
    "type": "application/octet-stream",
    "uploaded_at": "2026-05-28T12:00:00.000Z"
  },
  "file_size": 52428800
}

Chunk requests return immediately and do not run your ProcessFlow. Only the final form submission starts the process.

Recommended flow

  1. When the user opens the page, generate one _execution_id (for example crypto.randomUUID()).
  2. For each file, let Plupload upload chunks. Set unique_id to Plupload’s per-file id (file.id) so chunks for different files do not collide.
  3. Wait until every file returns "completed": true.
  4. POST the remaining form fields (name, email, and so on) to the same WebApp URL with the same _execution_id. Do not attach file inputs again—the files are already stored.

Plupload example

Include Plupload from your preferred CDN, then configure chunk size at or below 8 MiB (for example 8 * 1024 * 1024 bytes).

<form id="uploadForm">
  <input type="text" name="full_name" required />
  <input type="email" name="email" required />
  <button type="button" id="pickfiles">Choose files</button>
  <button type="submit" id="submitForm">Send</button>
  <div id="progress"></div>
</form>

<script src="https://cdn.jsdelivr.net/npm/plupload@3.1.5/js/plupload.min.js"></script>
<script>
  const uploadUrl = window.location.href;
  const executionId = crypto.randomUUID();
  const completedFiles = [];

  const uploader = new plupload.Uploader({
    runtimes: "html5",
    browse_button: "pickfiles",
    url: uploadUrl,
    chunk_size: 8 * 1024 * 1024,
    max_file_size: "1024mb",
    multipart_params: {
      _execution_id: executionId,
    },
    init: {
      BeforeUpload(up, file) {
        up.setOption("multipart_params", {
          _execution_id: executionId,
          unique_id: file.id,
        });
      },
      UploadProgress(up, file) {
        document.getElementById("progress").textContent =
          `${file.name}: ${file.percent}%`;
      },
      FileUploaded(up, file, response) {
        const result = JSON.parse(response.response);
        if (!result.success) {
          throw new Error(result.message ?? "Chunk upload failed");
        }
        if (result.completed && result.file) {
          completedFiles.push(result.file);
        }
      },
      Error(up, err) {
        console.error(err.message);
      },
    },
  });

  uploader.init();

  document.getElementById("uploadForm").addEventListener("submit", async (e) => {
    e.preventDefault();

    uploader.start();

    uploader.bind("UploadComplete", async () => {
      const formData = new FormData();
      formData.append("full_name", document.querySelector('[name="full_name"]').value);
      formData.append("email", document.querySelector('[name="email"]').value);
      formData.append("_execution_id", executionId);

      const response = await fetch(uploadUrl, { method: "POST", body: formData });
      const payload = await response.json();
      if (!payload.success) {
        throw new Error(payload?.error?.message ?? payload?.message ?? "Submit failed");
      }
    });
  });
</script>

Plupload notes:

  • Plupload sends chunk, chunks, and name automatically when chunk_size is set. You must add unique_id and _execution_id yourself (see BeforeUpload above).
  • The default file field name is file, which matches the WebApp upload handler.
  • Set chunk_size no larger than 8 MiB per request.
  • Use FileUploaded and completed: true to know when a file is ready before you submit the form.

When to prefer simple upload instead

Single-request FormData is simpler to build and test. Prefer it unless you need chunking for size, progress, or retries.

ProcessFlow: using uploaded files

After the WebApp runs your process, uploaded files are available through the files service in step code. The service reads metadata for the _execution_id that was attached to the process input.

Always check that the service exists and that at least one file was uploaded before you process content.

if (files == null) {
  return {
    success: false,
    error: {
      code: "NO_FILES_SERVICE",
      message: "No file context is available for this run.",
    },
  };
}

const uploadedFiles = files.getFiles();
if (!uploadedFiles || uploadedFiles.length === 0) {
  return {
    success: false,
    error: {
      code: "NO_FILES",
      message: "Please attach at least one file.",
    },
  };
}

What getFiles() returns

Each entry describes one uploaded file:

FieldMeaning
field_nameForm field name (often file for chunked uploads)
indexPosition when multiple files were uploaded
original_nameName from the user’s device
stored_nameName on disk under the execution folder
relative_pathTenant-relative directory for this execution (for example processdata/<execution_id>)
sizeSize in bytes
typeMIME type when known
uploaded_atISO timestamp

Use original_name when calling readFile().

Common operations

List all files

const uploadedFiles = files.getFiles();

Get metadata for one file

const invoice = files.getFile("invoice.pdf");

Read file content

try {
  const content = files.readFile("invoice.pdf");
  // Parse, scan, or forward content
} catch (e) {
  return {
    success: false,
    error: { code: "READ_FAILED", message: String(e.message ?? e) },
  };
}

Upload to an external system (TypeScript)

const result = await files.uploadToExternal(
    "invoice.pdf",
    "https://api.example.com/upload",
    [
        headers: [
            "Authorization: Bearer your-token",
        ],
    ]
);

End-to-end validation example

This step rejects unsupported MIME types and returns a summary for the rest of the process:

const allowedTypes = new Set(["application/pdf", "image/png", "image/jpeg"]);

if (files == null) {
  return {
    success: false,
    error: { code: "NO_FILES_SERVICE", message: "No file context available." },
  };
}

const uploadedFiles = files.getFiles();
if (!uploadedFiles?.length) {
  return {
    success: false,
    error: { code: "NO_FILES", message: "Please attach at least one file." },
  };
}

const processed = [];
for (const fileMeta of uploadedFiles) {
  if (!allowedTypes.has(fileMeta.type)) {
    return {
      success: false,
      error: {
        code: "INVALID_FILE_TYPE",
        message: `Unsupported file type: ${fileMeta.original_name}`,
      },
    };
  }

  const content = files.readFile(fileMeta.original_name);
  processed.push({
    name: fileMeta.original_name,
    mime_type: fileMeta.type,
    size: fileMeta.size,
    preview: String(content).slice(0, 200),
  });
}

return {
  success: true,
  data: {
    submitted_by: process_input.full_name ?? null,
    file_count: processed.length,
    files: processed,
  },
  message: "Files uploaded and processed successfully.",
};

Process input may also include _attachments (metadata array) and _execution_id. Step code should still use files.getFiles() so behavior stays the same for simple and chunked uploads.

Validate with API tests

Use multipart curl against your published WebApp URL or API execute endpoint to confirm behavior before debugging UI code.

Simple upload

curl -X POST "https://your-webapp.example.com/my-form" \
  -F "full_name=Jamie Example" \
  -F "attachments[]=@/path/to/invoice.pdf"

Single chunk (illustrative)

EXEC_ID="$(uuidgen)"
curl -X POST "https://your-webapp.example.com/my-form" \
  -F "_execution_id=${EXEC_ID}" \
  -F "chunk=0" \
  -F "chunks=1" \
  -F "unique_id=upload-1" \
  -F "name=invoice.pdf" \
  -F "file=@/path/to/invoice.pdf"

Troubleshooting

SymptomWhat to check
files is nullForm used POST; process has file capability; upload completed before the process ran
getFiles() is emptyFiles were sent; for chunked uploads, same _execution_id on every chunk and on final submit; wait for completed: true
Chunk always completed: falseMissing or changing unique_id between chunks; wrong chunk / chunks values
Chunk rejectedChunk larger than 8 MiB; reduce Plupload chunk_size
readFile() failsName matches original_name from getFiles(); wrap in try/catch

Related guides

Reliable uploads come from a clear browser contract (_execution_id for chunking), validation in ProcessFlow, and consistent error codes in your step responses.