# File uploads Accept file uploads on your forms with plain multipart HTML or presigned JavaScript uploads. Files stay linked to the right submission in Form Plume. Form Plume supports file fields for resumes, screenshots, signed PDFs, photos, and other submission attachments. Files are stored separately from the submission's text fields, then linked back to the submission in the dashboard. There are two ways to upload files: | Method | Best for | | --- | --- | | Plain multipart form | No-JS sites, simple contact forms, small attachments | | Presigned uploads | JavaScript apps, progress bars, larger client-side flows | The default policy is **10 files per submission**, **10 MB per file**, and storage is counted against the tenant's plan. ## Plain HTML uploads For a normal HTML form, add `enctype="multipart/form-data"` and use a file input. The browser sends the file bytes directly to Form Plume with the rest of the form. ```html
``` That is the whole setup. Text fields land in the submission body. File fields become attachments linked to the submission. Use `multiple` when a field can include more than one file: ```html ``` > Plain multipart uploads are the easiest path for most forms. If you need > upload progress, retries, or want file bytes to bypass the Form Plume API > process, use the presigned flow below. ## Presigned uploads The presigned flow is a three-step process: 1. Ask Form Plume for temporary upload slots. 2. Upload each file directly to object storage with the returned URL and headers. 3. Submit the form text and include `_uploads` references so Form Plume can attach those files to the submission. Use this flow when you are building a JavaScript app or custom uploader. ### 1. Request upload slots Call the presign endpoint with the files you intend to upload: ```js const file = document.querySelector('input[name="resume"]').files[0]; const presignRes = await fetch( "https://api.formplume.com/v1/forms/{public_slug}/uploads/presign", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ files: [ { field: "resume", filename: file.name, content_type: file.type || "application/octet-stream", size: file.size, }, ], }), }, ); const { uploads } = await presignRes.json(); ``` For public forms, no auth header is required. For [protected forms](/docs/forms/private-forms), send the same access key you use to submit: ```js headers: { "Content-Type": "application/json", "Accept": "application/json", "Authorization": "Bearer fp_live_...", } ``` The path segment accepts either the form UUID or the public slug. ### 2. Upload the bytes Each upload slot includes a short-lived URL, method, headers, and object key. Use the URL exactly as returned: ```js const slot = uploads[0]; await fetch(slot.url, { method: slot.method, headers: slot.headers, body: file, }); ``` The slot's `key` is not a download URL. It is the storage reference you pass back to Form Plume in the final submission. ### 3. Submit the form Send your normal submission and include `_uploads`. For JSON submissions, `_uploads` can be an array: ```js const submitRes = await fetch("https://api.formplume.com/f/{public_slug}", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify({ name: "Ada Lovelace", email: "ada@example.com", message: "Here is my resume.", _uploads: uploads.map((slot) => ({ field: slot.field, key: slot.key, })), }), }); const result = await submitRes.json(); ``` For a normal form post, `_uploads` must be a JSON string: ```html ``` Form Plume links any matching pending upload to the new submission. Unknown, expired, foreign, or already-linked keys are ignored so a bad upload reference does not block the text submission. ## Downloading files Files are downloaded from the [dashboard](/docs/submissions/attachments) or the [Form Plume API](/docs/api). The download endpoint returns a short-lived redirect to object storage: ```text GET /v1/submissions/{submission_id}/files/{file_id}/download ``` Only authenticated dashboard/API callers from the owning tenant can download submission files. ## Errors Upload validation errors are machine-readable: | Code | What it means | | --- | --- | | `uploads_unavailable` | File storage is not configured | | `no_files` | The presign request did not include any files | | `too_many_files` | The submission has more files than the form allows | | `invalid_file` | A file was empty, unreadable, or had an invalid size | | `file_too_large` | A file exceeds the form's per-file byte limit | | `unsupported_type` | A file's content type is not allowed | | `storage_quota_exceeded` | The tenant has reached the plan's storage limit | | `file_not_ready` | A presigned upload has not finished finalizing yet | See [Success and errors](/docs/forms/success-and-errors) for the full response shape.